From bb49cf54f81381c34275b32bfea5a055774ac36b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 15 Aug 2026 19:04:15 +0200 Subject: [PATCH 01/34] feat(cli): add init --template to scaffold from bundled examples --- README.md | 2 ++ package.json | 1 + src/cli/runCli.test.ts | 51 ++++++++++++++++++++++++++++ src/cli/runCli.ts | 21 ++++++++++-- src/compiler/initProject.test.ts | 58 +++++++++++++++++++++++++++++++- src/compiler/initProject.ts | 52 ++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fa4944f7..91b2fdd9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Node.js 22+ required. See [source install](#from-source) for local development. ```bash spawnfile init # scaffold an agent (defaults to openclaw) +spawnfile init --list-templates # list bundled example templates +spawnfile init --template mixed-runtime-org # scaffold from an example org spawnfile validate # check the graph spawnfile view . # read-only graph view; writes no files spawnfile compile # lower to runtime-native output diff --git a/package.json b/package.json index 702b8bbe..2386b886 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "files": [ "dist", + "examples", "moltnet-releases.json", "runtimes.yaml" ], diff --git a/src/cli/runCli.test.ts b/src/cli/runCli.test.ts index 6df5e86a..f5fa3579 100644 --- a/src/cli/runCli.test.ts +++ b/src/cli/runCli.test.ts @@ -1393,6 +1393,57 @@ describe("runCli", () => { expect(stdout[0]).toContain("initialized"); }); + it("lists available templates without scaffolding", async () => { + const listInitTemplates = vi.fn(async () => ["single-agent", "mixed-runtime-org"]); + const initProject = vi.fn(); + + const stdout: string[] = []; + const exitCode = await runCli( + ["init", "--list-templates"], + { + stderr: () => undefined, + stdout: (message) => stdout.push(message), + }, + { initProject, listInitTemplates }, + ); + + expect(exitCode).toBe(0); + expect(listInitTemplates).toHaveBeenCalled(); + expect(initProject).not.toHaveBeenCalled(); + expect(stdout).toEqual(["single-agent", "mixed-runtime-org"]); + }); + + it("initializes a project from a template", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "spawnfile-cli-template-init-"), + ); + temporaryDirectories.push(directory); + + const initProject = vi.fn(async () => ({ + createdFiles: [path.join(directory, "Spawnfile")], + directory, + })); + + const stdout: string[] = []; + const exitCode = await runCli( + ["init", directory, "--template", "single-agent"], + { + stderr: () => undefined, + stdout: (message) => stdout.push(message), + }, + { initProject }, + ); + + expect(exitCode).toBe(0); + expect(initProject).toHaveBeenCalledWith({ + directory, + runtime: undefined, + team: undefined, + template: "single-agent", + }); + expect(stdout[0]).toContain("initialized"); + }); + it("adds an agent member to a team project without requiring --runtime", async () => { const addAgentProject = vi.fn(async () => ({ createdFiles: [ diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 9843cc96..aa8d27b5 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -23,6 +23,7 @@ import { clearProjectModelFallbacks, compileProject, initProject, + listInitTemplates, publishProject, upProject, removeProjectSurface, @@ -92,6 +93,7 @@ export interface CliHandlers { addTeamProject: typeof addTeamProject; clearProjectModelFallbacks: typeof clearProjectModelFallbacks; importClaudeCodeAuth: typeof importClaudeCodeAuth; importCodexAuth: typeof importCodexAuth; importEnvFile: typeof importEnvFile; initProject: typeof initProject; + listInitTemplates: typeof listInitTemplates; initializeTargetSecretSourceLifecycle: typeof initializeTargetSecretSourceLifecycle; provisionCredentials: typeof provisionCredentials; exportRunArtifacts: typeof exportRunArtifacts; @@ -117,7 +119,7 @@ const createDefaultHandlers = (): CliHandlers => ({ importClaudeCodeAuth, importCodexAuth, importEnvFile, initializeTargetSecretSourceLifecycle, provisionCredentials, exportRunArtifacts, downDeployment, - initProject, listRuntimeAdapters, removeProjectSurface, requireAuthProfile, + initProject, listInitTemplates, listRuntimeAdapters, removeProjectSurface, requireAuthProfile, runProject, setProjectPrimaryModel, setProjectRuntime, upProject, buildUpReceipt, consumeImageUp, devActivityProject, devApplyProject, devRestartProject, devStopProject, devUpProject, setProjectSurfaceAccess, showProjectSurfaces, syncProjectAuth @@ -234,11 +236,24 @@ export const runCli: RunCli = async ( .argument("[path]", "Directory to initialize", process.cwd()) .option("--team", "Initialize a team project") .option("--runtime ", "Runtime for agent scaffolds") - .action(async (inputPath: string, options: { runtime?: string; team?: boolean }) => { + .option("--template ", "Scaffold from a bundled example template") + .option("--list-templates", "List available example templates and exit") + .action(async ( + inputPath: string, + options: { runtime?: string; team?: boolean; template?: string; listTemplates?: boolean } + ) => { + if (options.listTemplates) { + const templates = await handlers.listInitTemplates(); + for (const template of templates) { + streams.stdout(template); + } + return; + } const result = await handlers.initProject({ directory: inputPath, runtime: options.runtime, - team: options.team + team: options.team, + template: options.template }); streams.stdout(`initialized ${result.directory}`); emitFileLines(streams, "created", result.createdFiles); diff --git a/src/compiler/initProject.test.ts b/src/compiler/initProject.test.ts index 275fc522..72b2ae96 100644 --- a/src/compiler/initProject.test.ts +++ b/src/compiler/initProject.test.ts @@ -12,7 +12,7 @@ import { } from "../filesystem/index.js"; import { isAgentManifest, loadManifest } from "../manifest/index.js"; -import { initProject } from "./initProject.js"; +import { initProject, listInitTemplates } from "./initProject.js"; const temporaryDirectories: string[] = []; const getRuntimeName = (runtime: unknown): string | undefined => @@ -148,4 +148,60 @@ describe("initProject", () => { /Unknown runtime adapter/ ); }); + + it("lists the bundled example templates", async () => { + const templates = await listInitTemplates(); + expect(templates).toContain("single-agent"); + expect(templates).toContain("mixed-runtime-org"); + expect(templates).toEqual([...templates].sort()); + }); + + it("scaffolds a project from an example template", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-template-init-")); + temporaryDirectories.push(directory); + + const result = await initProject({ directory, template: "single-agent" }); + const loadedManifest = await loadManifest(path.join(directory, "Spawnfile")); + + expect(result.createdFiles.length).toBeGreaterThan(0); + await expect(fileExists(path.join(directory, "Spawnfile"))).resolves.toBe(true); + await expect(readUtf8File(path.join(directory, ".gitignore"))).resolves.toContain(".spawn/"); + if (!isAgentManifest(loadedManifest.manifest)) { + throw new Error("Expected agent manifest from the single-agent template"); + } + }); + + it("rejects an unknown template with the available list", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-bad-template-init-")); + temporaryDirectories.push(directory); + + await expect(initProject({ directory, template: "ghost-org" })).rejects.toThrow( + /Unknown template "ghost-org"\. Available templates:/ + ); + }); + + it("rejects --template combined with --team or --runtime", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-template-conflict-init-")); + temporaryDirectories.push(directory); + + await expect(initProject({ directory, template: "single-agent", team: true })).rejects.toThrow( + /cannot be combined/ + ); + await expect( + initProject({ directory, template: "single-agent", runtime: "openclaw" }) + ).rejects.toThrow(/cannot be combined/); + }); + + it("reports only template-contributed files, not pre-existing directory contents", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-template-scope-init-")); + temporaryDirectories.push(directory); + const preExisting = path.join(directory, "NOTES.md"); + await writeUtf8File(preExisting, "keep me\n"); + + const result = await initProject({ directory, template: "single-agent" }); + + expect(result.createdFiles).not.toContain(preExisting); + expect(result.createdFiles.some((file) => file.endsWith("Spawnfile"))).toBe(true); + await expect(fileExists(preExisting)).resolves.toBe(true); + }); }); diff --git a/src/compiler/initProject.ts b/src/compiler/initProject.ts index e08329c0..9de730de 100644 --- a/src/compiler/initProject.ts +++ b/src/compiler/initProject.ts @@ -1,6 +1,9 @@ import path from "node:path"; +import { readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { + copyDirectory, ensureDirectory, ensureGitignoreEntry, fileExists, @@ -14,10 +17,24 @@ export interface InitProjectOptions { directory?: string; runtime?: string; team?: boolean; + template?: string; } const DEFAULT_AGENT_RUNTIME = "openclaw"; +// Bundled example org projects double as `init --template` starting points. +// Resolves to the package's examples/ dir in both dev (src/) and the published +// package (dist/), so examples/ must ship in package.json "files". +const EXAMPLES_ROOT = fileURLToPath(new URL("../../examples", import.meta.url)); + +export const listInitTemplates = async (): Promise => { + const entries = await readdir(EXAMPLES_ROOT, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +}; + export const initProject = async ( options: InitProjectOptions = {} ): Promise<{ createdFiles: string[]; directory: string }> => { @@ -32,6 +49,13 @@ export const initProject = async ( ); } + if (options.template && (options.team || options.runtime)) { + throw new SpawnfileError( + "validation_error", + "--template cannot be combined with --team or --runtime" + ); + } + if (options.team && options.runtime) { throw new SpawnfileError( "validation_error", @@ -39,8 +63,36 @@ export const initProject = async ( ); } + // Resolve and validate the template before creating anything, so an unknown + // template name never leaves an empty directory behind. + let templateSource: string | undefined; + if (options.template) { + const templates = await listInitTemplates(); + if (!templates.includes(options.template)) { + throw new SpawnfileError( + "validation_error", + `Unknown template "${options.template}". Available templates: ${templates.join(", ")}` + ); + } + templateSource = path.join(EXAMPLES_ROOT, options.template); + } + await ensureDirectory(directory); + if (templateSource) { + await copyDirectory(templateSource, directory); + await ensureGitignoreEntry(directory, `${DEFAULT_OUTPUT_DIRECTORY}/`); + // Report only the files this template contributed — never pre-existing + // contents of the target directory. + const sourceEntries = await readdir(templateSource, { recursive: true, withFileTypes: true }); + const createdFiles = sourceEntries + .filter((entry) => entry.isFile()) + .map((entry) => + path.join(directory, path.relative(templateSource!, path.join(entry.parentPath, entry.name)))) + .sort(); + return { createdFiles, directory }; + } + const createdFiles: string[] = [manifestPath]; const gitignorePath = path.join(directory, ".gitignore"); const hadGitignore = await fileExists(gitignorePath); From 9c50852ad7c2fd7ece0a8026290185d14ca964a6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 23 Aug 2026 23:49:10 +0200 Subject: [PATCH 02/34] feat(daimon): compile autonomous runtime organizations --- DAIMON_RUNTIME_MIGRATION_PLAN.md | 301 +++++++++++++ README.md | 42 +- .../daimon-spawnfile-current-blockers.html | 135 ++++++ .../fixture-owned-composition-boundary.html | 112 +++++ docs/diagrams/spawnfile-architecture.html | 224 ++++++++++ docs/diagrams/spawnfile-boundary-audit.md | 38 ++ docs/diagrams/spawnfile-public-api.html | 256 +++++++++++ docs/diagrams/spawnfile-yaml-spec.html | 309 ++++++++++++++ examples/daimon-org/Spawnfile | 2 +- examples/daimon-org/TEAM.md | 2 +- examples/daimon-org/agents/mapper/Spawnfile | 2 +- examples/daimon-org/teams/review/Spawnfile | 2 +- .../teams/review/agents/reviewer/Spawnfile | 2 +- examples/daimon-public-host/AGENTS.md | 3 + examples/daimon-public-host/Spawnfile | 21 + examples/jungian-daimon-org/TEAM.md | 2 +- .../teams/luna/agents/animus/Spawnfile | 2 +- .../luna/agents/representative/Spawnfile | 2 +- .../teams/luna/agents/shadow/Spawnfile | 2 +- .../teams/selene/agents/animus/Spawnfile | 2 +- .../selene/agents/representative/Spawnfile | 2 +- .../teams/selene/agents/shadow/Spawnfile | 2 +- examples/mixed-runtime-org/Spawnfile | 2 +- examples/mixed-runtime-org/TEAM.md | 6 +- .../agents/localist/AGENTS.md | 2 +- .../agents/localist/Spawnfile | 6 +- package-lock.json | 4 +- package.json | 7 +- runtime-images/AGENTS.md | 18 +- runtime-images/daimon/Dockerfile | 59 ++- runtimes.yaml | 6 +- scripts/AGENTS.md | 3 +- scripts/build-local-daimon-runtime.mjs | 123 ++++++ scripts/build-local-daimon-runtime.test.mjs | 30 ++ scripts/build-local-moltnet.mjs | 289 ++++++------- scripts/build-local-moltnet.test.mjs | 28 ++ scripts/verify-package-closure.mjs | 55 ++- specs/CONTAINERS.md | 31 +- specs/DISTRIBUTION.md | 2 +- specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md | 6 +- specs/RUNTIMES.md | 66 ++- specs/SPEC.md | 22 +- specs/SURFACES.md | 12 +- specs/TARGETS.md | 197 +++++---- src/AGENTS.md | 1 + src/cli/AGENTS.md | 7 + src/cli/capabilitiesCommand.test.ts | 16 + src/cli/capabilitiesCommand.ts | 23 + src/cli/capabilitiesReceipt.test.ts | 96 +++++ src/cli/capabilitiesReceipt.ts | 118 ++++++ src/cli/composedLifecycleContractSet.ts | 293 +++++++++++++ src/cli/evidenceExportHelperCommand.test.ts | 47 ++ src/cli/evidenceExportHelperCommand.ts | 72 ++++ src/cli/lifecycleMachine.ts | 13 +- src/cli/runCli.test.ts | 45 +- src/cli/runCli.ts | 6 + src/cli/targetCommands.test.ts | 15 + src/cli/targetCommands.ts | 10 +- src/cli/targetConfigPreparedPlan.ts | 74 ++++ src/cli/targetConfigResolver.test.ts | 170 +++++++- src/cli/targetConfigResolver.ts | 400 ++++-------------- src/cli/targetConfigResolverCommand.ts | 6 + src/cli/targetConfigResolverContracts.ts | 93 ++++ src/cli/targetConfigResolverDocker.ts | 66 +++ .../targetConfigResolverValidation.test.ts | 111 +++++ src/cli/targetConfigResolverValidation.ts | 93 ++++ src/cli/targetDefaultAuthorities.ts | 5 + src/cli/targetDefaultConfig.test.ts | 29 ++ src/cli/targetDefaultConfig.ts | 25 +- src/cli/targetDefaultConfigStdin.test.ts | 22 + src/cli/targetDefaultConfigStdin.ts | 8 +- ...faultHandlerFactory.preparedHelper.test.ts | 128 ++++++ src/cli/targetDefaultHandlerFactory.ts | 25 +- src/cli/targetDefaultHandlers.test.ts | 1 + src/cli/targetDefaultHandlers.ts | 6 +- ...argetWorldClockCrossProcess.test-helper.ts | 5 +- src/cli/upCommand.ts | 57 ++- src/cli/upLifecycleRecovery.test.ts | 249 +++++++++++ src/cli/upLifecycleRecovery.ts | 264 +++++++++--- src/compiler/AGENTS.md | 31 +- src/compiler/buildCompilePlan.test.ts | 4 +- src/compiler/compileProject.test.ts | 28 +- src/compiler/containerArtifacts.ts | 13 +- src/compiler/containerArtifactsPlans.test.ts | 55 +++ src/compiler/containerArtifactsPlans.ts | 22 +- src/compiler/containerArtifactsRender.test.ts | 34 +- src/compiler/containerArtifactsRender.ts | 91 ++-- src/compiler/containerArtifactsTypes.ts | 3 + ...containerDaimonUidEntrypointRender.test.ts | 354 ++++++++++++++++ .../containerDaimonUidEntrypointRender.ts | 263 ++++++++++++ .../containerEntrypointRender.test.ts | 4 +- src/compiler/containerEntrypointRender.ts | 20 +- src/compiler/containerStateOwnershipRender.ts | 103 +++++ src/compiler/daimonTelemetryArtifacts.test.ts | 8 +- src/compiler/daimonTelemetryArtifacts.ts | 15 +- src/compiler/localMoltnetAuthority.ts | 176 ++++++++ src/compiler/mixedRuntimeOrg.test.ts | 10 +- src/compiler/moltnetBinaries.test.ts | 77 +++- src/compiler/moltnetBinaries.ts | 47 +- ...ltnetExternalParticipantResolution.test.ts | 9 +- .../moltnetNestedOrganization.test.ts | 3 + src/compiler/moltnetResolution.ts | 28 +- src/compiler/moltnetRoomMemberships.ts | 15 +- src/compiler/moltnetRuntimeConfig.ts | 15 +- .../organizationExternalParticipants.ts | 200 +++++++++ src/compiler/organizationIdentity.test.ts | 7 +- src/compiler/organizationIdentity.ts | 321 +++----------- src/compiler/organizationIdentityGraph.ts | 128 ++++++ src/compiler/publicDaimonHost.test.ts | 61 +++ src/compiler/runProject.runner.test.ts | 29 +- src/compiler/runProject.test.ts | 54 +++ src/compiler/runProject.ts | 23 +- src/compiler/runProjectAuth.test.ts | 6 +- src/compiler/runProjectAuth.ts | 23 +- src/compiler/runProjectDocker.test.ts | 14 +- src/compiler/runProjectDocker.ts | 64 ++- src/compiler/runProjectDockerDaimonGuards.ts | 168 ++++++++ src/compiler/runProjectLifecycle.ts | 2 +- src/compiler/upProject.test.ts | 47 +- src/compiler/upProject.ts | 316 +++++--------- src/compiler/upProjectHandoff.ts | 285 +++++++++++++ .../upProjectOrganizationHandoff.test.ts | 84 +++- src/compiler/upReceipt.test.ts | 34 ++ src/compiler/upReceipt.ts | 43 +- src/compiler/worldBindings.test.ts | 11 + src/deployment/AGENTS.md | 3 +- src/deployment/index.ts | 1 + src/deployment/lifecycleCompletion.test.ts | 53 ++- .../lifecycleCompletionContracts.ts | 37 ++ src/deployment/lifecycleCompletionPaths.ts | 9 +- .../lifecycleCompletionPublication.ts | 8 +- src/deployment/lifecycleCompletionStore.ts | 6 +- src/deployment/lifecycleUpRecords.ts | 177 ++++++++ ...ganizationHandoffAuthorityFsClient.test.ts | 23 +- .../organizationHandoffAuthorityFsWorker.ts | 125 +++++- .../organizationHandoffAuthorityStore.test.ts | 24 ++ src/deployment/upLifecycleRecoveryState.ts | 49 +++ src/deployment/upReceiptTypes.ts | 26 +- src/dev/project.test.ts | 16 +- src/e2e/AGENTS.md | 6 +- src/e2e/cliMemory.ts | 4 +- src/e2e/cliSmoke.ts | 4 +- .../daimonLocalAutonomousCredentials.test.ts | 53 +++ src/e2e/daimonLocalAutonomousCredentials.ts | 43 ++ src/e2e/daimonRuntimeInstanceLookup.test.ts | 18 +- src/e2e/daimonRuntimeInstanceLookup.ts | 25 +- src/e2e/memoryIntegration.ts | 48 +-- src/e2e/scenarios.test.ts | 6 +- src/e2e/scenarios.ts | 4 +- src/e2e/types.ts | 2 +- src/evidenceExportHelper/AGENTS.md | 38 ++ src/evidenceExportHelper/CLAUDE.md | 1 + src/evidenceExportHelper/boundedExecutor.ts | 38 ++ src/evidenceExportHelper/copyAssets.mjs | 8 + src/evidenceExportHelper/helperProgram.mjs | 156 +++++++ src/evidenceExportHelper/index.ts | 4 + .../preparedAuthority.test.ts | 98 +++++ src/evidenceExportHelper/preparedAuthority.ts | 225 ++++++++++ .../preparedBuilder.test.ts | 232 ++++++++++ src/evidenceExportHelper/preparedBuilder.ts | 189 +++++++++ .../preparedBuilderTypes.ts | 20 + src/evidenceExportHelper/recipe.test.ts | 83 ++++ src/evidenceExportHelper/recipe.ts | 98 +++++ src/ownership/AGENTS.md | 6 - .../simfileRunOperatorContract.test.ts | 86 ---- .../simfileRunOperatorInputs.test.ts | 115 ----- src/ownership/simfileRunOperatorInputs.ts | 182 -------- src/report/types.ts | 13 +- src/runtime/AGENTS.md | 5 +- src/runtime/container.fallback.test.ts | 17 +- src/runtime/container.test.ts | 44 +- src/runtime/container.ts | 90 ++-- src/runtime/daimon/AGENTS.md | 17 + src/runtime/daimon/CLAUDE.md | 1 + src/runtime/daimon/adapter.test.ts | 265 ++++++++++++ src/runtime/daimon/adapter.ts | 119 ++++++ src/runtime/daimon/config.ts | 156 +++++++ src/runtime/daimon/contract-manifest.json | 1 + src/runtime/daimon/contract-manifest.sha256 | 1 + src/runtime/daimon/contractManifest.test.ts | 123 ++++++ src/runtime/daimon/contractManifest.ts | 167 ++++++++ src/runtime/daimon/runAuth.test.ts | 271 ++++++++++++ src/runtime/daimon/runAuth.ts | 219 ++++++++++ src/runtime/install.test.ts | 6 +- src/runtime/install.ts | 4 + src/runtime/pi/adapter.ts | 5 - src/runtime/registry.ts | 19 +- src/runtime/types.ts | 12 + src/target/AGENTS.md | 16 +- src/target/dockerArtifacts.test.ts | 53 +++ src/target/dockerArtifactsProvider.ts | 38 +- .../dockerArtifactsProviderRace.test.ts | 63 +++ src/target/dockerBaseImage.test.ts | 12 + src/target/dockerBaseImage.ts | 12 + ...ckerCommandExecutor.publicArtifact.test.ts | 108 +++++ src/target/dockerCommandExecutor.ts | 69 ++- src/target/dockerCommandExecutorCore.ts | 10 +- .../dockerPublicArtifactSnapshot.test.ts | 136 ++++-- src/target/dockerPublicArtifactSnapshot.ts | 59 ++- src/target/dockerTarget.ts | 2 +- src/target/dockerTargetBinding.ts | 5 +- src/target/dockerTargetExecFile.test.ts | 82 ++++ src/target/dockerTargetExecFile.ts | 172 ++++++++ src/target/dockerWorldClock.test.ts | 2 +- src/target/dockerWorldReadiness.test.ts | 2 +- src/target/dockerWorldService.test.ts | 2 +- src/target/dockerWorldServiceCleanup.test.ts | 2 +- src/target/dockerWorldServiceProvider.test.ts | 14 +- src/target/dockerWorldServiceProvider.ts | 14 +- src/target/dockerWorldServiceRecovery.test.ts | 2 +- src/target/evidenceExport.test.ts | 20 +- src/target/evidenceExport.ts | 11 +- src/target/evidenceExportOperations.test.ts | 4 +- src/target/evidenceExportOperationsTestKit.ts | 6 +- src/target/evidenceExportProvider.ts | 21 +- src/target/publicArtifactSnapshot.test.ts | 30 ++ src/target/publicArtifactSnapshot.ts | 61 ++- src/target/topologyAttestation.test.ts | 2 +- tsconfig.build.json | 1 + 219 files changed, 11248 insertions(+), 2073 deletions(-) create mode 100644 DAIMON_RUNTIME_MIGRATION_PLAN.md create mode 100644 docs/diagrams/daimon-spawnfile-current-blockers.html create mode 100644 docs/diagrams/fixture-owned-composition-boundary.html create mode 100644 docs/diagrams/spawnfile-architecture.html create mode 100644 docs/diagrams/spawnfile-boundary-audit.md create mode 100644 docs/diagrams/spawnfile-public-api.html create mode 100644 docs/diagrams/spawnfile-yaml-spec.html create mode 100644 examples/daimon-public-host/AGENTS.md create mode 100644 examples/daimon-public-host/Spawnfile create mode 100644 scripts/build-local-daimon-runtime.mjs create mode 100644 scripts/build-local-daimon-runtime.test.mjs create mode 100644 scripts/build-local-moltnet.test.mjs create mode 100644 src/cli/capabilitiesCommand.test.ts create mode 100644 src/cli/capabilitiesCommand.ts create mode 100644 src/cli/capabilitiesReceipt.test.ts create mode 100644 src/cli/capabilitiesReceipt.ts create mode 100644 src/cli/composedLifecycleContractSet.ts create mode 100644 src/cli/evidenceExportHelperCommand.test.ts create mode 100644 src/cli/evidenceExportHelperCommand.ts create mode 100644 src/cli/targetConfigPreparedPlan.ts create mode 100644 src/cli/targetConfigResolverContracts.ts create mode 100644 src/cli/targetConfigResolverDocker.ts create mode 100644 src/cli/targetConfigResolverValidation.test.ts create mode 100644 src/cli/targetConfigResolverValidation.ts create mode 100644 src/cli/targetDefaultHandlerFactory.preparedHelper.test.ts create mode 100644 src/cli/upLifecycleRecovery.test.ts create mode 100644 src/compiler/containerDaimonUidEntrypointRender.test.ts create mode 100644 src/compiler/containerDaimonUidEntrypointRender.ts create mode 100644 src/compiler/containerStateOwnershipRender.ts create mode 100644 src/compiler/localMoltnetAuthority.ts create mode 100644 src/compiler/organizationExternalParticipants.ts create mode 100644 src/compiler/organizationIdentityGraph.ts create mode 100644 src/compiler/publicDaimonHost.test.ts create mode 100644 src/compiler/runProjectDockerDaimonGuards.ts create mode 100644 src/compiler/upProjectHandoff.ts create mode 100644 src/deployment/lifecycleUpRecords.ts create mode 100644 src/deployment/upLifecycleRecoveryState.ts create mode 100644 src/e2e/daimonLocalAutonomousCredentials.test.ts create mode 100644 src/e2e/daimonLocalAutonomousCredentials.ts create mode 100644 src/evidenceExportHelper/AGENTS.md create mode 120000 src/evidenceExportHelper/CLAUDE.md create mode 100644 src/evidenceExportHelper/boundedExecutor.ts create mode 100644 src/evidenceExportHelper/copyAssets.mjs create mode 100644 src/evidenceExportHelper/helperProgram.mjs create mode 100644 src/evidenceExportHelper/index.ts create mode 100644 src/evidenceExportHelper/preparedAuthority.test.ts create mode 100644 src/evidenceExportHelper/preparedAuthority.ts create mode 100644 src/evidenceExportHelper/preparedBuilder.test.ts create mode 100644 src/evidenceExportHelper/preparedBuilder.ts create mode 100644 src/evidenceExportHelper/preparedBuilderTypes.ts create mode 100644 src/evidenceExportHelper/recipe.test.ts create mode 100644 src/evidenceExportHelper/recipe.ts delete mode 100644 src/ownership/simfileRunOperatorContract.test.ts delete mode 100644 src/ownership/simfileRunOperatorInputs.test.ts delete mode 100644 src/ownership/simfileRunOperatorInputs.ts create mode 100644 src/runtime/daimon/AGENTS.md create mode 120000 src/runtime/daimon/CLAUDE.md create mode 100644 src/runtime/daimon/adapter.test.ts create mode 100644 src/runtime/daimon/adapter.ts create mode 100644 src/runtime/daimon/config.ts create mode 100644 src/runtime/daimon/contract-manifest.json create mode 100644 src/runtime/daimon/contract-manifest.sha256 create mode 100644 src/runtime/daimon/contractManifest.test.ts create mode 100644 src/runtime/daimon/contractManifest.ts create mode 100644 src/runtime/daimon/runAuth.test.ts create mode 100644 src/runtime/daimon/runAuth.ts create mode 100644 src/target/dockerArtifactsProviderRace.test.ts create mode 100644 src/target/dockerBaseImage.test.ts create mode 100644 src/target/dockerBaseImage.ts create mode 100644 src/target/dockerCommandExecutor.publicArtifact.test.ts create mode 100644 src/target/dockerTargetExecFile.test.ts create mode 100644 src/target/dockerTargetExecFile.ts diff --git a/DAIMON_RUNTIME_MIGRATION_PLAN.md b/DAIMON_RUNTIME_MIGRATION_PLAN.md new file mode 100644 index 00000000..5ec1050b --- /dev/null +++ b/DAIMON_RUNTIME_MIGRATION_PLAN.md @@ -0,0 +1,301 @@ +# Spawnfile → Daimon runtime migration plan + +## Objective + +Replace the current `runtime: daimon` alias to Spawnfile's generated Pi +application with one compiled Daimon organization-runtime configuration and +one `daimon-runtime` process in the existing shared Daimon container. Spawnfile +continues to compile the organization graph, workspaces, Moltnet topology, +schedules, credentials and deployment; Daimon owns every agent turn, engine +process, engine authentication home, MCP lifecycle and process cleanup. + +This is an ecosystem migration only. No Clank & Slop source, terminology, +personas, fixtures or publication behavior enters Spawnfile or Daimon. + +## Contract fixed by the current Daimon release + +Target the public package contract in `@noopolis/daimon@0.2.0`: + +- package export: `@noopolis/daimon/runtime`; +- executable: `daimon-runtime`; +- config: `noopolis.daimon.organization-runtime.v1`; +- host fields: `bindHost`, `port`, `controlTokenEnv`; +- agent fields: `id`, `name`, `instructions`, `workspacePath`, + `runtimeHomePath`, `engine.kind` (`codex`, `grok`, `agy` only); +- runtime control API: authenticated `/v1/wake`, `/v1/health`, and + `/v1/activity`. + +The v1 host limit is 32 agents. The new Daimon target builder must count all +resolved `runtime: daimon` agents before emitting any file and reject 33 or +more with the deterministic diagnostic: + +```text +Daimon organization runtime v1 supports at most 32 agents; found . Split the organization across explicit runtime boundaries. +``` + +It must never silently shard, create multiple Daimon targets, alter the +organization graph, or treat nested teams as a reason to bypass the count. +Add unit coverage for 32 accepted and 33 rejected agents, asserting the exact +diagnostic and no partial target/config emission. + +The config is deliberately strict. It contains no schedules, Moltnet +configuration, commands, environment maps, secret values, MCP declarations, +provider traffic, organization graph or deployment data. + +## Current ownership to retire + +`src/runtime/pi/adapter.ts` currently exposes `daimonAdapter` as +`{ ...piAdapter, name: "daimon" }`. That makes `runtime: daimon` compile the +same generated Pi application as `runtime: pi`. + +The following Daimon-alias behavior must be removed from the Daimon path, not +ported: + +- generated `pi-app.json`, `runtime/app.mjs`, `runtime/schedule.mjs`, models + config and Pi control/activity servers in `src/runtime/pi/appTemplate.ts`, + `containerTargets.ts`, `appSource.ts`, `appCoreSource.ts`, + `appControlSource.ts`, `appActivitySource.ts`, and `appScheduleSource.ts`; +- generated Codex/Grok/AGY execution, prompts, output files and child process + handling in `appCliEnginesSource.ts` and its helpers; +- generated engine auth copying/staging and engine-home construction in + `src/runtime/pi/runAuth.ts`; +- Pi-specific engine, models, tool, raw-capture and schedule option lowering + in `appAgentConfig.ts`, `appTemplateTypes.ts`, and `appScriptedEngine.ts`. + +Those files remain available only for an explicit legacy `runtime: pi` path +until a separately approved removal. They must not be imported by the new +Daimon adapter. `openclaw` and `picoclaw` are untouched. + +## Implementation slices + +### 1. Add an independent Daimon adapter + +Create `src/runtime/daimon/` with its own `AGENTS.md` and `CLAUDE.md` symlink: + +- `adapter.ts`: validate the closed Daimon engine choices, reject unsupported + Pi-only options, compile documents/skills into the agent workspace, and + advertise only capabilities Daimon's public contract supports. +- `organizationConfig.ts`: pure lowering from resolved agent nodes plus + resolved container paths to the exact v1 config. Use the public version + literal and a local structural type mirror; do not import Daimon source or + internals. +- `containerTargets.ts`: group all `runtime: daimon` agents into a single, + stable target (for example `daimon-org`); emit only + `daimon-runtime.json` plus workspace files. It must never emit an app, + generated engine runner, generated MCP server, or generated auth script. +- `auth.ts`: declares the minimal per-agent mount destinations expected by + Daimon (`.codex/auth.json`, `.grok/auth.json`, or + `.antigravity-cli/antigravity-oauth-token`) but does not parse, copy, + transform or log credentials. +- focused tests beside every module, including a two-agent mixed-engine fake + config assertion and rejection matrices. + +Replace the current `daimonAdapter` export in `src/runtime/pi/adapter.ts` with +the new adapter imported from `src/runtime/daimon/adapter.ts`; keep `piAdapter` +as a distinct legacy adapter. Update `src/runtime/registry.ts` only as needed +to register the separate implementation. + +### 2. Compile physical paths before config serialization + +`src/compiler/containerTargetPlanResolution.ts` currently derives one target +home/workspace path. Extend the Daimon target shape so each agent receives a +unique pre-created pair under the single target, for example: + +```text +/var/lib/spawnfile/instances/daimon/daimon-org/workspace/agents/ +/var/lib/spawnfile/instances/daimon/daimon-org/runtime-homes/ +``` + +`src/runtime/daimon/organizationConfig.ts` consumes these final absolute +paths. The entrypoint creates them before `daimon-runtime` starts, with +workspace-safe permissions and exact `0700` runtime-home permissions, never +after the host has started. Update `containerArtifactsTypes.ts`, +`containerArtifactsPlans.ts`, `containerTargetResources.ts`, and their tests +only to carry this per-agent path data; do not add an organization graph to +Daimon configuration. + +Keep existing resource links and per-agent workspace relocation. Update +`daimonTelemetryArtifacts.ts` to mount Daimon's actual per-agent telemetry +locations only after confirming the public runtime's telemetry contract; do +not retain the old generated-Pi path by assumption. + +### 3. Container recipe, pin and entrypoint + +Introduce a source-free, separately versioned **generic Daimon runtime +distribution**. It is built outside a customer/org compile from one reviewed +release recipe and contains exactly the public `@noopolis/daimon` package plus +the reviewed Codex, Grok and AGY CLI installations required by that Daimon +release. It contains no organization source/configuration, workspace content, +credentials, browser state, Moltnet configuration, provider invocation, or +agent behavior. + +For every generic-image release, publish an immutable image digest and a +machine-readable capability receipt containing at least: Daimon package +version, engine executable canonical identities, bounded local +`--version`/capability digests, supported engine kinds, architecture and +build/provenance identity. The release build verifies all three local CLI +capabilities without performing a model/auth turn. The image has a generic +health command that proves the installed `daimon-runtime` binary and its +declared capability receipt agree; it does not start an organization. + +`runtimes.yaml` must pin the image by immutable digest plus the matching +receipt identity (not a mutable tag). Spawnfile selects/verifies this identity +at build/deploy and records only public image/receipt identifiers. It never +installs a CLI, chooses CLI argv, probes provider auth, reads an auth home, or +executes an engine. `runtime-images/daimon/Dockerfile` becomes the generic +distribution build recipe rather than a per-org workaround; the release +pipeline owns build, update, provenance and capability verification. + +The local-development override remains an explicit test/development seam only: +it supplies a built generic runtime image plus matching receipt, is rejected +when digest/receipt/version/capability identity disagrees, and is never inferred +from sibling source checkouts. Recovery resolves the exact recorded immutable +image/receipt pair from the deployment record; it never rebuilds, pulls +`latest`, scans a checkout, or substitutes an engine installation. + +Update these synchronized release identities to Daimon `0.2.0` and the new +generic image release identity: + +- `runtimes.yaml` (`ref`, image tag); +- `runtime-images/daimon/Dockerfile` build arg/default install; +- `package.json` `runtime:daimon-image` script; +- `src/runtime/container.ts` Daimon package version/install recipe; +- specs and tests that name `0.1.2`. + +The generated organization image copies the pinned generic runtime artifact +only. It must install no Codex/Grok/AGY CLI packages and run no provider +installer. Daimon itself resolves, verifies and invokes those engines at +runtime. + +Set the new adapter's container metadata to start: + +```text +daimon-runtime run --config /daimon-runtime.json +``` + +Update `containerEntrypointRender.ts` so the Daimon target receives only: + +- canonical config path; +- one generated control-token env name/value; +- its port/bind wiring; +- per-agent physical-root preparation; +- opaque credential mounts/materialization supplied by Spawnfile's auth + layer; +- existing workspace-resource and Moltnet node setup. + +It must not set engine command arguments, engine model knobs, broad inherited +environment maps, or credentials in JSON/config files. Update readiness +waiting to recognize Daimon's `/v1/health` contract instead of Pi `/healthz`. + +### 4. Auth boundary migration + +Replace `preparePiRuntimeAuth` use for `runtime: daimon` with the new +Daimon-specific preparation API. Spawnfile remains responsible for selecting +the user-authorized credential source and mounting it into the pre-created +per-agent root, but it may only materialize the exact minimal artifact for the +selected engine. + +Required changes: + +- add strict per-engine credential-source/mount planning in `src/auth/` and + `src/runtime/daimon/auth.ts` without expanding general auth authority; +- use the existing ephemeral run-auth directory lifecycle in + `src/compiler/runProjectAuth.ts` and `runProjectLifecycle.ts` so detached + containers keep needed bind sources and cleanup remains safe; +- never recursively stage host homes, browser state, config files, MCP state + or cookies; +- preserve the existing Pi auth preparer for explicit `runtime: pi`, + OpenClaw and PicoClaw unchanged; +- test only fake JSON artifacts and assert no secret bytes/path leak into + reports, Docker labels, emitted config or receipts. + +The current Daimon v1 engine set has no `pi`, `scripted`, Claude, arbitrary +endpoint, model, tool or MCP config. `runtime: daimon` must reject those +requests with a clear migration diagnostic rather than silently lowering them +to Spawnfile code. Existing scripted fixture coverage stays on `runtime: pi` +until Daimon publishes an explicit compatible contract. + +### 5. Moltnet and schedules without cognition in Spawnfile + +Keep all existing Moltnet topology compilation in compiler modules. Adapt the +generated Moltnet node delivery URL/body to Daimon's authenticated `/v1/wake` +wire contract and generated control-token environment binding. The bridge +continues to decide delivery; Daimon only receives targeted wakes. + +Schedules require an explicit compatibility decision before implementation: +the strict v1 Daimon config intentionally has no schedule field. Do **not** +smuggle schedules into it. The migration will add a deterministic Spawnfile +schedule-delivery sidecar/entrypoint component only if it can send the same +typed authenticated wake requests without creating agent turns, selecting +work, or reading agent state. It remains Spawnfile schedule ownership; Daimon +owns execution. If that component is not ready in the first slice, +`runtime: daimon` accepts `disabled` schedules only and fails other schedules +at compile time. The current generated Pi schedule runner must not survive on +the Daimon path. + +### 6. Reports, status, artifacts and developer tooling + +Update only the Daimon-facing consumers of generated Pi paths: + +- `src/compiler/upReceipt.ts`, report types and `engine_by_node_id` disclosure + to read the Daimon target's engine map; +- Daimon status probes and `src/dev/*` Pi-only config/activity/hot-apply code + to either add a narrow Daimon control-client implementation or explicitly + retain Pi-only behavior; no generic Pi fallback for Daimon; +- `src/e2e/daimonRuntimeInstanceLookup.ts`, `daimonOrg.ts`, memory integration + and artifact export paths to the public runtime configuration/health/activity + behavior; +- `specs/RUNTIMES.md`, `CONTAINERS.md`, `SURFACES.md`, runtime docs and + `website/src/content/docs/runtimes/daimon.md` to describe the real boundary. + +No changes are required to Simfile's public contract. It continues using +Spawnfile CLI receipts/artifacts and never imports either runtime. + +### 7. Compatibility and deletion order + +1. Land the standalone Daimon adapter and target config with no change to + `runtime: pi`. +2. Switch only `runtime: daimon` to it; preserve source manifests using that + runtime name, with documented option rejection where v1 lacks support. +3. Update `examples/daimon-org` to use Codex/Grok/AGY declarations supported + by Daimon and remove its generated-Pi-specific assertions. +4. Keep explicit `runtime: pi` as temporary compatibility for existing + scripted/Pi consumers; mark it deprecated only in a separate release after + an inventory of consumers and a migration guide. +5. Delete only Daimon-alias imports/tests/generation branches after compile, + E2E and package consumers use the new adapter. Do not delete shared Pi + sources while `runtime: pi` exists. + +## Verification graph + +Run focused deterministic gates first: + +1. Pure config lowering validates against the public v1 schema and exact + config parser in a packed/installable Daimon package fixture. +2. Compile a two-agent fake organization (Codex + Grok) into exactly one + Daimon target/config, unique real roots, no engine commands/auth values, + no generated Pi/CLI/MCP source, and one daemon start command. +3. Generic runtime-distribution tests verify the immutable image/receipt pair, + exact Daimon/CLI capability identities, source-free contents, local override + mismatch rejection and recovery from only recorded immutable identities. +4. Fake host E2E uses only fake executable/auth fixtures: start one + `daimon-runtime`, send two authenticated wakes, prove same-agent serial + execution/cross-agent concurrency, health/activity responses, and full + shutdown with no listener/child survivor. +5. Compile tests prove Moltnet bridge delivery shape and schedule behavior + selected in Slice 5, without live provider traffic. +6. Auth tests prove per-agent minimal mounts, exact mode expectations and no + credentials in reports/receipts/logs. +7. Run Spawnfile typecheck/unit/package closure; then a Docker fake-engine + E2E with a bounded external watchdog and post-run process/listener + inventory. Real subscription and browser checks happen only after these + gates pass and remain opt-in. + +## Non-goals + +- No Spawnfile execution of Codex, Grok, AGY, provider adapters, MCP servers, + agent prompts, process groups or model-auth home semantics. +- No Daimon import of Spawnfile compiler internals, Moltnet topology, schedule + policy, workspace compilation or deployment records. +- No changes to OpenClaw/PicoClaw behavior. +- No live Docker/provider/login/push work during this planning slice. diff --git a/README.md b/README.md index 91b2fdd9..8ed0a97d 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,49 @@ spawnfile status . --live # inspect the detached deployme spawnfile publish . --tag you/my-agent:1.0.0 # compile + build + verify + push ``` -Compiled output lands under `.spawn/` by default, including a `Dockerfile`, `entrypoint.sh`, `.env.example`, and a prebuilt `container/rootfs/` tree. `spawnfile build` uses the pinned runtime artifacts from `runtimes.yaml`; it does not rebuild runtimes from source. Daimon, OpenClaw, and PicoClaw use published copyable artifact images by default, so normal prompt/config edits reuse their dependency layers. To test a local runtime artifact instead, set `SPAWNFILE_DAIMON_RUNTIME_IMAGE`, `SPAWNFILE_OPENCLAW_RUNTIME_IMAGE`, or `SPAWNFILE_PICOCLAW_RUNTIME_IMAGE` to a local image tag. For `build`/`up` on a docker `--context`, Moltnet release assets are staged for that context's architecture (`amd64` or `arm64`); for local-only manual compile targeting a fixed architecture, set `SPAWNFILE_MOLTNET_TARGET_ARCH=amd64|arm64`. +Compiled output lands under `.spawn/` by default, including a `Dockerfile`, `entrypoint.sh`, `.env.example`, and a prebuilt `container/rootfs/` tree. `spawnfile build` uses the pinned runtime artifacts from `runtimes.yaml`; it does not rebuild runtimes from source. Daimon, OpenClaw, and PicoClaw use published copyable artifact images by default, so normal prompt/config edits reuse their dependency layers. Daimon accepts only its exact immutable image digest plus matching capability receipt; mutable/local overrides are fail-closed. OpenClaw and PicoClaw retain their explicit local-image overrides. For `build`/`up` on a docker `--context`, Moltnet release assets are staged for that context's architecture (`amd64` or `arm64`); for local-only manual compile targeting a fixed architecture, set `SPAWNFILE_MOLTNET_TARGET_ARCH=amd64|arm64`. `spawnfile status` is read-only. By default it shows authored and compiled state without Docker, runtime, or Moltnet calls. With `--live`, it reads the selected detached deployment record, inspects the recorded Docker target, runs adapter-owned runtime probes, and checks Moltnet metadata without reading message bodies. Add `--logs` for a redacted Docker log tail, or `--watch` to refresh status continuously. For a remote Docker context where the local record is missing, pass `--context ` with `--live` to recover the deployment from Spawnfile container labels. -`spawnfile dev` is the source-backed interactive loop. It uses `.spawn-dev/` by default, starts a detached dev deployment with `spawnfile dev up`, and can hot-apply one Daimon runtime agent with `spawnfile dev apply --agent ` without restarting the rest of the org. Hot apply recompiles source, copies the selected agent workspace, Daimon config, matching Moltnet node configs, and managed Moltnet server configs into the running container, loads it through the Daimon control endpoint, and starts only that agent's Moltnet bridges when it is new. `spawnfile dev activity` reads the generated Daimon app's bounded activity buffer as JSON lines so operators can see queued wakes, turn starts/completions, runtime event types, output completions, and errors without mixing those diagnostics into Moltnet chat. Running managed Moltnet servers keep their current in-memory room membership until an operator-token `moltnet apply` or server restart reconciles the copied server config. +`spawnfile dev` is the source-backed interactive loop. In Phase A, hot apply and its bounded activity buffer remain `runtime: pi` behavior; public `runtime: daimon` hosts do not yet support hot apply, schedules, MCP, or agent surfaces. A future control-plane adapter will integrate those concerns through Daimon's public APIs rather than generated Pi code. + +Before automating Spawnfile, query the installed CLI rather than inferring +support from its package version: + +```bash +spawnfile capabilities --json +``` + +This command only reads Spawnfile's packaged version and emits one strict +`spawnfile.capabilities.v1` JSON document. It does not read standard input, +write files, or contact Docker. The receipt identifies the target resolver, +closed composed-lifecycle command set, optional model-auth behavior, local +evidence helper, and typed terminal-artifact absence contracts. Capabilities +describe the installed CLI surface; target and auth preflight can still fail +for a particular machine or project. See +[`specs/TARGETS.md`](specs/TARGETS.md#capability-discovery). + +For a local Docker target, Spawnfile prepares and journals the package-owned +helper under its private target state: + +```bash +# node:22-bookworm-slim must already be present in this Docker context. +spawnfile helper prepare-evidence-export \ + --context default \ + --json + +spawnfile target resolve_config \ + --context default \ + --evidence-destination "$PWD/.spawn-local/evidence.tar" \ + --prepare-evidence-helper +``` + +The helper command uses only the explicitly named local context, performs a +network-disabled build from package-shipped source, never pulls or pushes, and +keeps a fsynced pending transaction authority before its first Docker mutation. +The public result is only a versioned opaque handle and digest; reuse re-attests +the exact context, daemon, base config, platform, recipe, and image config +identity. No registry manifest or caller-managed authority file is required. Compiled images are self-describing: `spawnfile publish` pushes one to any OCI registry, and anyone can run it with no source — `spawnfile up you/my-agent:1.0.0 --deployment prod --detach --auth-profile me` — or inspect what it needs first with `spawnfile status you/my-agent:1.0.0`. See [`specs/DISTRIBUTION.md`](specs/DISTRIBUTION.md). diff --git a/docs/diagrams/daimon-spawnfile-current-blockers.html b/docs/diagrams/daimon-spawnfile-current-blockers.html new file mode 100644 index 00000000..fa4b7184 --- /dev/null +++ b/docs/diagrams/daimon-spawnfile-current-blockers.html @@ -0,0 +1,135 @@ + + + + + + Spawnfile and Daimon — current integration boundary + + + + +
+

Current-state architecture · Local development

+

Spawnfile declares the organization. Daimon runs each agent.

+

The architecture is aligned; integration is paused at two artifact-boundary guarantees. Neither problem changes runtime ownership.

+
+ + Spawnfile and Daimon current integration boundary + Architecture showing Spawnfile compiling an organization into verified Daimon workspaces, Daimon running autonomous agents through Codex, Grok, and Antigravity engines, and two blocked guarantees involving declarative contracts and safe local bundle publication. + + + + + + + + + + + DECLARATION + BUILD + + + ARTIFACT BOUNDARY + + + RUNNING AGENT + + + + + COMPILE + + + CONSUME + + + RUN ONE TURN + + + CODEX · GROK · AGY + + + MESSAGES + + + + + + + + + + + + + SOURCE + Spawnfile organization + agents · teams · models · Moltnet + + + COMPILER + Spawnfile + compile workspace + deployment config + Does not run Codex, Grok, or AGY + + + + TRUST GATE + Verified Daimon artifact + package + contracts + digests + LOCAL AUTHORITY — NOT PRODUCTION + 25 tests pass; integration remains blocked + + + + RUNTIME + Daimon + one agent · one wake · one turn + Owns engine execution + + Engine CLIs + subscription runtimes + + Moltnet + message transport only + + + + BLOCKER 1 · CONTRACT EXTRACTION + Regex cannot prove which JavaScript executes. + Daimon must build a static contract manifest; + Spawnfile should verify bytes, never execute them. + + BLOCKER 2 · SAFE PUBLICATION + A parent path can change during rename. + Build privately; let a trusted caller place it, + or use descriptor-relative native operations. + + + + LEGEND + + approved ownership flow + + Daimon engine call + + blocked until guarantee holds + The boundary is the problem—not the ownership model. + Target: Daimon publishes declarative contracts → Spawnfile verifies and packages → Daimon executes autonomous agents. + +
+
DEFAULT DIAGRAM-DESIGN PROFILE · STATIC HTML · LOCAL ARTIFACT
+
+ + diff --git a/docs/diagrams/fixture-owned-composition-boundary.html b/docs/diagrams/fixture-owned-composition-boundary.html new file mode 100644 index 00000000..d4e2dbdf --- /dev/null +++ b/docs/diagrams/fixture-owned-composition-boundary.html @@ -0,0 +1,112 @@ + + + + + + Fixture-owned composition boundary + + + + +
+

Architecture pattern · Fixture-owned composition

+

Keep the platform generic. Put the story in the fixture.

+

Tiny Football established the separation. The Daimon E2E should reuse that boundary: ecosystem packages expose stable seams; a disposable fixture supplies agents, prompts, research goals, and expected conversation.

+
+ + Fixture-owned composition boundary + Comparison showing the Tiny Football precedent and the proposed Daimon autonomous research test, with generic platform contracts separated from fixture-owned scenario behavior. + + + + + + + + + + + GENERIC PLATFORM + + + PRECEDENT · FIXTURE + + + TARGET · E2E FIXTURE + + + + DELEGATE + + LAUNCH + + + PUBLIC CONTRACT + + LIFECYCLE + + COMPILE + WAKE + + ASSERT CHAT + + + + WORLD API + Simfile + world + clock + observations + No organization orchestration + + COMPILER + Spawnfile + org graph + workspace + deployment + Never executes model engines + + RUNTIME + Daimon + one agent · one wake · one turn + Owns Codex · Grok · AGY execution + + Moltnet + rooms + DMs + Transport only + + + + SCENARIO + Tiny Football fixture + world rules + production runner + Historical precedent + + BOUNDARY + Delegated lifecycle + fixture calls stable public seams + No fixture behavior in packages + + SCENARIO + Autonomous research fixture + agents + prompts + today's task + Disposable local E2E only + + PROOF + E2E assertions + engine parents + Moltnet history + No Spawnfile cognition edge + + + + BOUNDARY RULE + Packages provide capabilities. Fixtures provide narratives. + Fixture-specific names, prompts, goals, timing, and expected messages stop below the boundary; only versioned contracts cross upward. + +
+
DEFAULT DIAGRAM-DESIGN PROFILE · DOC-WIDE · LOCAL STATIC HTML
+
+ + diff --git a/docs/diagrams/spawnfile-architecture.html b/docs/diagrams/spawnfile-architecture.html new file mode 100644 index 00000000..cb9d0596 --- /dev/null +++ b/docs/diagrams/spawnfile-architecture.html @@ -0,0 +1,224 @@ + + + + + + Spawnfile — current architecture + + + + + Spawnfile current architecture and integration gaps + A left-to-right secure paved-road architecture. Authored Spawnfiles pass through manifest validation, graph resolution, runtime adapters, container artifacts, an immutable image, deployment, and status and recovery. A target control plane joins deployment from below. Dashed red callouts mark missing Daimon credential provisioning and authenticated wake ingress. A bottom strip lists remaining priority-one boundary defects. + + + + + + + + + + + + + + CURRENT IMPLEMENTATION · TRACKED + UNTRACKED WORKTREE + Spawnfile architecture + The paved road compiles and deploys organizations; cognition stays inside agent runtimes. + + + live + + not integrated + + + + + AUTHORING TRUST + + + TRUSTED COMPILER + + + ARTIFACT TRUST + + + DOCKER / RUNTIME + + + + + + + + + + + + + + + + + + Authoring + CLI + Spawnfiles · docs + skills · references + + + + + Manifest validation + parse · normalize + reject invalid source + + + + + Resolved org graph + inheritance · teams + resources · topology + + + + + Runtime adapters + Daimon · OpenClaw + PicoClaw · legacy Pi + + + + + Container artifacts + rootfs · entrypoint + config · reports + + + + + Target control plane + opaque handles · journal + network · secrets · handoff + + + + + Image + receipts + immutable runtime pins + public fingerprints + + + + + Deployment lifecycle + build · up · readiness + + + + + Status + evidence + inspect · export · down + receipts · recovery + durable records + + + + + + Generic Daimon image + Codex · Grok · AGY binaries + + + One Daimon host + N separate config roots + Daimon executes engines + + + + + + + + P0 · GAP 01 + Daimon engine auth is not provisioned + No minimal per-agent credential mount reaches the host. + + + + P0 · GAP 02 + Authenticated wake ingress is not wired + Moltnet/schedules cannot yet deliver typed wakes to Daimon’s public /v2/wakes surface. + + + + + + + P1 BOUNDARY STRIP + must clear before + production closure + + + + + + + MODEL INTENT + accepted, then discarded + with no degradation signal + + ISOLATION FLAG + restrict_to_workspace + accepted but not enforced + + STATUS + READINESS + still assumes generated Pi; + no Daimon health probe + + EVIDENCE + Daimon telemetry is not + durable or exportable + + RELEASE PIPELINE + runtime-image CI still uses + the obsolete build contract + + + + diff --git a/docs/diagrams/spawnfile-boundary-audit.md b/docs/diagrams/spawnfile-boundary-audit.md new file mode 100644 index 00000000..d8bc3859 --- /dev/null +++ b/docs/diagrams/spawnfile-boundary-audit.md @@ -0,0 +1,38 @@ +# Spawnfile boundary audit + +Current-tree audit accompanying the architecture, public API, and YAML-model diagrams. + +## Boundary conclusion + +The new public Daimon path no longer executes Codex, Grok, or AGY inside Spawnfile. Spawnfile compiles the organization and Daimon configuration; Daimon owns agent execution. + +The migration is not operationally complete. Credential provisioning and wake ingress remain missing from the live public Daimon path, while several legacy Pi-shaped integration surfaces still describe or inspect Daimon incorrectly. + +## P0 — critical + +- **No Daimon engine credentials:** Spawnfile creates empty agent runtime homes but has no public provisioning path for Daimon's required Codex, Grok, or AGY credential artifacts. A real Daimon deployment cannot pass engine readiness. +- **No usable Daimon wake ingress:** the live path rejects schedules, Moltnet, and other surfaces; exposes no Spawnfile wake operation; and binds the host to container loopback. A running host cannot perform useful work. + +## P1 — correctness + +- `run` promises compile, build, and run but does not build the image. +- Dev, status, and dormant Moltnet lowering still assume the old generated-Pi Daimon shape. +- Explicit Daimon model selection, subagent topology, sandbox intent, and `restrict_to_workspace` are accepted or reported more strongly than they are lowered. +- Daimon evidence and readiness are not yet represented by durable, runtime-native health/export contracts. +- Parent-agent and ancestor-team resources cross inheritance boundaries prohibited by the detailed specification. +- Shared team documents enter the ordinary agent document pipeline instead of remaining namespaced team context. +- Environment substitution and publication metadata are specified but not implemented. +- Manifest path and symlink enforcement is weaker than the specification states. +- The provider-neutral target barrel exposes Docker-specific types. + +## Borderline P2 — important + +- `artifacts export --json` wraps the advertised versioned index in an unversioned outer object. +- `publish` can report an unknown digest while recommending a mutable tag. +- Environment identifiers, URLs, and schedule expressions are not consistently validated at the manifest boundary. +- Public Daimon still lacks a completed live end-to-end acceptance test. +- Agent homes inside one Daimon container are namespaces, not OS security boundaries; agents share a container user and filesystem trust domain. + +## Carried implementation blocker + +The in-progress hardened deployment-artifact publisher has one remaining borderline P2 at its loop cap: replay rejects a legitimate executable prefix created as mode `0700` before a crash, rather than safely repairing it to the intended `0755` mode. diff --git a/docs/diagrams/spawnfile-public-api.html b/docs/diagrams/spawnfile-public-api.html new file mode 100644 index 00000000..ef2d1ec3 --- /dev/null +++ b/docs/diagrams/spawnfile-public-api.html @@ -0,0 +1,256 @@ + + + + + + Spawnfile public API — data flow + + + +
+ + Spawnfile public CLI and API surface + + A two-lane data-flow diagram. The operator lane moves from authoring and inspection through + compile, compiled package, build and publish, deployment, and operations. The machine lane + moves from capability discovery through target lifecycle operations to versioned receipts. + Callouts flag the run command's missing build step, legacy Pi-shaped Daimon integration, + and the unversioned artifacts-export JSON wrapper. + + + + + + + + + + + + + + Spawnfile · public surface + Source to organization lifecycle + The compiler owns organization shape and deployment; versioned contracts carry machine coordination. + + + Operator / project workflow + Machine / composed lifecycle + + + + + + 01 · source + Author & inspect + init · add · model + runtime · surface · auth + validate · view · runtimes + + + + + + 02 · lower + Compile + resolve graph + validate adapters + emit workspaces + + + + + + 03 · artifact + Compiled package + Dockerfile · entrypoint + runtime configs · rootfs + + distribution-report.v1 + + + + + + 04 · distribute + Build / publish + local OCI image + verify · registry push + + spawnfile.image.v1 + + + + + + 05 · deploy + Up / run + container + volumes + deployment record v2 + + up-receipt.v1 + + + + + + 06 · operate + Observe / export + & stop + status · artifacts export + down · lifecycle lookup + status.v1 · index.v1 · down.v1 + + + + + + + + + + + + + + P1 · contract mismatch + run promises build, + but compiles then runs + an existing image tag. + + + + + + P1 · legacy boundary + Daimon dev / status / Moltnet + still assume Pi app IDs, routes, + ports, and config shape. + + + + + + + 07 · discover + Capabilities + closed command + contract inventory + + capabilities.v1 + + + + + + 08 · coordinate + Target + lifecycle operations + resolve config · prepare · select · attach · attest · activate + readiness · clock · snapshot · recover · cleanup + + + + + + 09 · handoff + Versioned receipts + target-resource.v1 · topology.v1 · lifecycle.v1 + up.v1 · export-index.v1 · down.v1 + + + + admit + + canonical JSON + + authorizes project-mode up + + + + + P2 · envelope mismatch + artifacts --json wraps export-index.v1 + in an unversioned outer object. + + + + + + data / authority flow + + confirmed boundary issue + + + Current implementation surface · nine primary nodes · provider traffic and agent cognition remain outside Spawnfile. + 1280 × 800 + +
+ + diff --git a/docs/diagrams/spawnfile-yaml-spec.html b/docs/diagrams/spawnfile-yaml-spec.html new file mode 100644 index 00000000..479611d0 --- /dev/null +++ b/docs/diagrams/spawnfile-yaml-spec.html @@ -0,0 +1,309 @@ + + + + + + Spawnfile YAML specification map + + + +
+ + Spawnfile YAML specification map + A nested tree showing common Spawnfile fields branching into agent and team declarations, their grouped surfaces, inheritance rules, current boundary drifts, and the resolution pipeline from authored YAML to runtime artifacts. + + Spawnfile · source model + YAML specification map + Current v0.1 architecture · authored intent stays separate from runtime execution + + + + inherits / lowers + + must not inherit + + current P1 drift + + + + + + Every Spawnfile + Common declaration + spawnfile_version: "0.1" · kind · name + description? · memory? · policy? + + + + + + + + + + kind: agent + One autonomous runtime identity + runtime · execution? · workspace? · environment? + surfaces? · schedule? · subagents? · expose?* (implemented, not specified) + + + + + + kind: team + Organization structure, not cognition + mode · lead? · external? · members · shared? · memory? · networks? + teams declare no runtime, execution, schedule or direct communication surfaces + + + + + + + + + + Runtime + execution + runtime + string or { name, options? } + execution.model + primary · fallback[] · auth · endpoint + execution.sandbox.mode + workspace · sandboxed · unrestricted + + P1 · capability reports can overstate + model, sandbox and subagents; + an ignored option drops authored intent. + + + + + Workspace + environment + docs + identity · soul · system · memory · + heartbeat · extras + skills[] · resources[] + SKILL.md · git | volume + env{} · secrets[] · packages[] · MCP[] + + P1 · parent resources leak into + subagents; declared Daimon env + does not reach the engine. + + + + + Edges + surfaces + chat + Moltnet + webhook + schedule + cron + every + disabled + subagents + + + + + + + + + Structure + shared surface + mode: hierarchical | swarm + lead? · external[]? · representative fallback + members[] + { id, ref } agent/team · or strict inline agent + shared.workspace + shared.environment + Direct agents inherit skills, resources, env, + secrets, packages and MCP; local keys win. + + P1 · ancestor resources leak through nested teams; + team docs enter agent role files. + + + + + State + coordination + memory[] + store · index · consolidation · retention + access.members → direct slots only + networks[] → Moltnet + rooms · managed/external server · auth + external_participants[] + root-only service · explicit DM attachment + Nested teams remain black boxes. + Only selected representatives cross upward. + + + + + subagents must not inherit workspace · environment · surfaces + + outer shared surface must not cross nested-team boundaries + + + + + Resolution strip + Authored YAML becomes runtime artifacts through explicit boundaries + + + + + + + 01 + Parse + validate + strict schema · local files + + 02 + Resolve graph + refs · cycles · stable nodes + + 03 + Compute effective intent + inheritance · merge · defaults + + 04 + Enforce capability policy + supported · degraded · unsupported + + 05 + Lower through adapters + workspace · config · image · report + + + Solid lines show authored containment or intended inheritance. Dashed lines mark explicit non-inheritance. Red rules identify current implementation drift, not desired schema behavior. + +
+ + diff --git a/examples/daimon-org/Spawnfile b/examples/daimon-org/Spawnfile index 644d1765..13e783c6 100644 --- a/examples/daimon-org/Spawnfile +++ b/examples/daimon-org/Spawnfile @@ -1,7 +1,7 @@ spawnfile_version: "0.1" kind: team name: daimon-org -description: "Spawnfile-owned org compiled into a single generated Daimon app" +description: "Legacy generated-Pi organization fixture with Moltnet and shared resources" shared: workspace: diff --git a/examples/daimon-org/TEAM.md b/examples/daimon-org/TEAM.md index e7fdf0f6..b7443c2e 100644 --- a/examples/daimon-org/TEAM.md +++ b/examples/daimon-org/TEAM.md @@ -1,4 +1,4 @@ # Daimon Org -This organization verifies that Spawnfile can compile multiple Daimon agents and +This legacy organization verifies that Spawnfile can compile multiple generated Pi agents and a nested team into one generated runtime app with shared resources and memory. diff --git a/examples/daimon-org/agents/mapper/Spawnfile b/examples/daimon-org/agents/mapper/Spawnfile index 2353b2a9..d68517a9 100644 --- a/examples/daimon-org/agents/mapper/Spawnfile +++ b/examples/daimon-org/agents/mapper/Spawnfile @@ -3,7 +3,7 @@ kind: agent name: mapper description: "Maps shared workspace state for the Daimon org." -runtime: daimon +runtime: pi execution: model: diff --git a/examples/daimon-org/teams/review/Spawnfile b/examples/daimon-org/teams/review/Spawnfile index ca04fd2d..2e0b4a2b 100644 --- a/examples/daimon-org/teams/review/Spawnfile +++ b/examples/daimon-org/teams/review/Spawnfile @@ -1,7 +1,7 @@ spawnfile_version: "0.1" kind: team name: daimon-review-team -description: "Nested review team for the generated Daimon app fixture" +description: "Nested review team for the legacy generated-Pi fixture" members: - id: reviewer diff --git a/examples/daimon-org/teams/review/agents/reviewer/Spawnfile b/examples/daimon-org/teams/review/agents/reviewer/Spawnfile index 61f9a3de..41bb1515 100644 --- a/examples/daimon-org/teams/review/agents/reviewer/Spawnfile +++ b/examples/daimon-org/teams/review/agents/reviewer/Spawnfile @@ -3,7 +3,7 @@ kind: agent name: reviewer description: "Reviews generated shared workspace notes." -runtime: daimon +runtime: pi execution: model: diff --git a/examples/daimon-public-host/AGENTS.md b/examples/daimon-public-host/AGENTS.md new file mode 100644 index 00000000..6425c55c --- /dev/null +++ b/examples/daimon-public-host/AGENTS.md @@ -0,0 +1,3 @@ +# Public Daimon Host + +Work only in this agent's workspace and return concise outcomes. diff --git a/examples/daimon-public-host/Spawnfile b/examples/daimon-public-host/Spawnfile new file mode 100644 index 00000000..0306dbee --- /dev/null +++ b/examples/daimon-public-host/Spawnfile @@ -0,0 +1,21 @@ +spawnfile_version: "0.1" +kind: agent +name: public-host-agent +description: "Minimal public Daimon organization-host fixture" + +runtime: + name: daimon + options: + engine: codex + +execution: + model: + primary: + provider: openai + name: gpt-5.4-mini + auth: + method: codex + +workspace: + docs: + system: AGENTS.md diff --git a/examples/jungian-daimon-org/TEAM.md b/examples/jungian-daimon-org/TEAM.md index 3e4ecfcb..af315f20 100644 --- a/examples/jungian-daimon-org/TEAM.md +++ b/examples/jungian-daimon-org/TEAM.md @@ -17,5 +17,5 @@ Use this seed for live wake checks: 2) The appropriate representative should route to its council room, collect one short grounded reply, and then answer in `commons`. Each council declares a durable Mneme bank. The fixture verifies that the -compiler maps those banks into the generated Daimon runtime and preserves the +compiler maps those banks into the legacy generated-Pi runtime and preserves the inner council room topology used for consultation and dream wakes. diff --git a/examples/jungian-daimon-org/teams/luna/agents/animus/Spawnfile b/examples/jungian-daimon-org/teams/luna/agents/animus/Spawnfile index c270606b..9c2059e5 100644 --- a/examples/jungian-daimon-org/teams/luna/agents/animus/Spawnfile +++ b/examples/jungian-daimon-org/teams/luna/agents/animus/Spawnfile @@ -4,7 +4,7 @@ name: luna-animus description: "Luna inner archetype (Antigravity CLI engine)" runtime: - name: daimon + name: pi options: engine: agy diff --git a/examples/jungian-daimon-org/teams/luna/agents/representative/Spawnfile b/examples/jungian-daimon-org/teams/luna/agents/representative/Spawnfile index 3aecb092..2c2e52aa 100644 --- a/examples/jungian-daimon-org/teams/luna/agents/representative/Spawnfile +++ b/examples/jungian-daimon-org/teams/luna/agents/representative/Spawnfile @@ -4,7 +4,7 @@ name: luna-representative description: "Luna representative (Codex engine)" runtime: - name: daimon + name: pi options: engine: codex diff --git a/examples/jungian-daimon-org/teams/luna/agents/shadow/Spawnfile b/examples/jungian-daimon-org/teams/luna/agents/shadow/Spawnfile index 123241ee..9885a992 100644 --- a/examples/jungian-daimon-org/teams/luna/agents/shadow/Spawnfile +++ b/examples/jungian-daimon-org/teams/luna/agents/shadow/Spawnfile @@ -4,7 +4,7 @@ name: luna-shadow description: "Luna inner archetype (Grok CLI engine)" runtime: - name: daimon + name: pi options: engine: grok diff --git a/examples/jungian-daimon-org/teams/selene/agents/animus/Spawnfile b/examples/jungian-daimon-org/teams/selene/agents/animus/Spawnfile index 231dd3f6..f94dd6da 100644 --- a/examples/jungian-daimon-org/teams/selene/agents/animus/Spawnfile +++ b/examples/jungian-daimon-org/teams/selene/agents/animus/Spawnfile @@ -4,7 +4,7 @@ name: selene-animus description: "Selene inner archetype (Antigravity CLI engine)" runtime: - name: daimon + name: pi options: engine: agy diff --git a/examples/jungian-daimon-org/teams/selene/agents/representative/Spawnfile b/examples/jungian-daimon-org/teams/selene/agents/representative/Spawnfile index 0555dd6a..f84a5003 100644 --- a/examples/jungian-daimon-org/teams/selene/agents/representative/Spawnfile +++ b/examples/jungian-daimon-org/teams/selene/agents/representative/Spawnfile @@ -4,7 +4,7 @@ name: selene-representative description: "Selene representative (Grok CLI engine)" runtime: - name: daimon + name: pi options: engine: grok diff --git a/examples/jungian-daimon-org/teams/selene/agents/shadow/Spawnfile b/examples/jungian-daimon-org/teams/selene/agents/shadow/Spawnfile index 1d717da7..c5ec32a8 100644 --- a/examples/jungian-daimon-org/teams/selene/agents/shadow/Spawnfile +++ b/examples/jungian-daimon-org/teams/selene/agents/shadow/Spawnfile @@ -4,7 +4,7 @@ name: selene-shadow description: "Selene inner archetype (grounding profile)" runtime: - name: daimon + name: pi options: engine: codex diff --git a/examples/mixed-runtime-org/Spawnfile b/examples/mixed-runtime-org/Spawnfile index 36a0257f..c12bf8ff 100644 --- a/examples/mixed-runtime-org/Spawnfile +++ b/examples/mixed-runtime-org/Spawnfile @@ -1,7 +1,7 @@ spawnfile_version: "0.1" kind: team name: mixed-runtime-org -description: "Mixed OpenClaw, PicoClaw, and Daimon runtime fixture" +description: "Mixed OpenClaw, PicoClaw, and legacy generated-Pi runtime fixture" mode: hierarchical lead: conductor diff --git a/examples/mixed-runtime-org/TEAM.md b/examples/mixed-runtime-org/TEAM.md index 8dfb9e76..b8578595 100644 --- a/examples/mixed-runtime-org/TEAM.md +++ b/examples/mixed-runtime-org/TEAM.md @@ -1,8 +1,8 @@ # Mixed Runtime Org -This fixture verifies that OpenClaw, PicoClaw, and the Spawnfile Daimon runtime can +This fixture verifies that OpenClaw, PicoClaw, and the legacy generated-Pi runtime can coexist in one compiled organization and share a Moltnet room. The memory fixture includes a team bank shared by OpenClaw, PicoClaw, and -Daimon, plus a Daimon-local bank. OpenClaw and PicoClaw receive Mneme through -generated MCP servers; Daimon receives direct in-process Mneme wiring. +Pi, plus a Pi-local bank. OpenClaw and PicoClaw receive Mneme through +generated MCP servers; Pi receives direct in-process Mneme wiring. diff --git a/examples/mixed-runtime-org/agents/localist/AGENTS.md b/examples/mixed-runtime-org/agents/localist/AGENTS.md index d77a3e03..7faf4cdb 100644 --- a/examples/mixed-runtime-org/agents/localist/AGENTS.md +++ b/examples/mixed-runtime-org/agents/localist/AGENTS.md @@ -1,6 +1,6 @@ # Localist -You are a Daimon agent backed by a local OpenAI-compatible model. +You are a legacy Pi agent backed by a local OpenAI-compatible model. When a Moltnet message asks you to reply, send through the Moltnet CLI rather than only answering internally. Use `moltnet send --network mixed_lab --target diff --git a/examples/mixed-runtime-org/agents/localist/Spawnfile b/examples/mixed-runtime-org/agents/localist/Spawnfile index 84819e0e..561d09e0 100644 --- a/examples/mixed-runtime-org/agents/localist/Spawnfile +++ b/examples/mixed-runtime-org/agents/localist/Spawnfile @@ -1,9 +1,9 @@ spawnfile_version: "0.1" kind: agent name: localist -description: "Daimon agent configured for a local OpenAI-compatible model" +description: "Pi agent configured for a local OpenAI-compatible model" -runtime: daimon +runtime: pi execution: model: @@ -21,7 +21,7 @@ execution: schedule: kind: every every: 10s - prompt: "Append one short line to ./shared-log/localist.md proving Daimon can use shared resources." + prompt: "Append one short line to ./shared-log/localist.md proving Pi can use shared resources." surfaces: moltnet: diff --git a/package-lock.json b/package-lock.json index 2b8d7f2e..e78b7908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spawnfile", - "version": "0.1.14", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spawnfile", - "version": "0.1.14", + "version": "0.1.17", "license": "MIT", "dependencies": { "@noopolis/stele": "0.0.2", diff --git a/package.json b/package.json index 2386b886..678bd48e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spawnfile", - "version": "0.1.14", + "version": "0.1.17", "description": "Canonical source compiler for autonomous agents and teams.", "license": "MIT", "type": "module", @@ -17,7 +17,7 @@ "node": ">=22.19.0" }, "scripts": { - "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node ./src/runtime/copyScaffoldAssets.mjs", + "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node ./src/evidenceExportHelper/copyAssets.mjs && node ./src/runtime/copyScaffoldAssets.mjs", "clean": "rm -rf coverage dist", "coverage": "vitest run --coverage", "dev": "tsx src/cli/index.ts", @@ -29,9 +29,10 @@ "runtime:images": "npm run runtime:openclaw-image && npm run runtime:picoclaw-image && npm run runtime:daimon-image", "runtime:openclaw-image": "docker build -f runtime-images/openclaw/Dockerfile -t noopolis/spawnfile-runtime-openclaw:2026.6.11-local runtime-images/openclaw", "runtime:picoclaw-image": "docker build -f runtime-images/picoclaw/Dockerfile -t noopolis/spawnfile-runtime-picoclaw:0.3.1-local runtime-images/picoclaw", - "runtime:daimon-image": "docker build -f runtime-images/daimon/Dockerfile -t noopolis/spawnfile-runtime-daimon:0.1.2-local --build-arg DAIMON_VERSION=0.1.2 --build-arg MNEME_VERSION=0.1.1 --build-arg PI_VERSION=0.79.10 runtime-images/daimon", + "runtime:daimon-image": "npm run build:local-daimon", "audit:generate": "tsx src/audit/auditCli.ts", "build:local-moltnet": "node ./scripts/build-local-moltnet.mjs", + "build:local-daimon": "node ./scripts/build-local-daimon-runtime.mjs", "verify:package-closure": "node ./scripts/verify-package-closure.mjs", "test:e2e:docker-auth": "tsx src/e2e/cli.ts", "test:e2e:daimon-memory-recall": "tsx src/e2e/cli.ts daimon-memory-recall", diff --git a/runtime-images/AGENTS.md b/runtime-images/AGENTS.md index 493e5eb6..f1cb8c82 100644 --- a/runtime-images/AGENTS.md +++ b/runtime-images/AGENTS.md @@ -6,11 +6,19 @@ images are built and pushed by `.github/workflows/runtime-images.yml` and pinned in this repo's `runtimes.yaml`. `src/runtime/container.ts` `COPY --from`s these images into generated organization Dockerfiles. -The `daimon/` image is built exactly like the others — a single Dockerfile -that installs the **published** `@noopolis/daimon`, `@noopolis/mneme`, and -`@earendil-works/pi-*` packages (build-args `DAIMON_VERSION`/`MNEME_VERSION`/ -`PI_VERSION`) and scratch-copies the artifact. It needs no Daimon source -checkout, and the daimon repo no longer builds this image. +The `daimon/` image is a separately versioned generic engine runtime. It +contains published Daimon plus exact Codex, Grok, and AGY CLI installations, +and carries a capability receipt recording those executable identities. It +contains no organization config, workspace, credentials, Moltnet state, or +browser. Spawnfile selects the immutable image digest and receipt from +`runtimes.yaml`; it never constructs engine argv, installs CLIs, or stages +engine auth. + +The image pipeline supplies pinned Grok/AGY release URLs plus SHA-256 values +and a canonical capability-receipt document. The resulting image is accepted +by Spawnfile only when its immutable image digest and the embedded receipt's +SHA-256 match `runtimes.yaml`; readiness/version probing happens inside +Daimon, never in Spawnfile. Runtime artifact images are copy sources for generated organization Dockerfiles. They must contain pinned runtime dependencies only, under diff --git a/runtime-images/daimon/Dockerfile b/runtime-images/daimon/Dockerfile index b06de062..b6b00121 100644 --- a/runtime-images/daimon/Dockerfile +++ b/runtime-images/daimon/Dockerfile @@ -1,20 +1,61 @@ # syntax=docker/dockerfile:1 +FROM daimon_package AS daimon_package + ARG NODE_VERSION=24 FROM node:${NODE_VERSION}-bookworm-slim AS build -ARG DAIMON_VERSION=0.1.2 -ARG MNEME_VERSION=0.1.1 -ARG PI_VERSION=0.79.10 +ARG CODEX_CLI_VERSION=0.142.3 +ARG GROK_CLI_URL +ARG GROK_CLI_SHA256 +ARG AGY_CLI_URL +ARG AGY_CLI_SHA256 +ARG DAIMON_CAPABILITY_RECEIPT_BASE64 +ARG DAIMON_MANIFEST_SHA256 +ARG DAIMON_PACKAGE_SHA256 +ARG DAIMON_SOURCE_SHA256 +ARG CODEX_CLI_SHA256 +ARG TARGETARCH ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon -RUN mkdir -p ${RUNTIME_ROOT} \ +COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz + +RUN test -n "${GROK_CLI_URL}" \ + && test -n "${GROK_CLI_SHA256}" \ + && test -n "${AGY_CLI_URL}" \ + && test -n "${AGY_CLI_SHA256}" \ + && test -n "${DAIMON_CAPABILITY_RECEIPT_BASE64}" \ + && test -n "${DAIMON_MANIFEST_SHA256}" \ + && test -n "${DAIMON_PACKAGE_SHA256}" \ + && test -n "${DAIMON_SOURCE_SHA256}" \ + && test -n "${CODEX_CLI_SHA256}" \ + && test -n "${TARGETARCH}" \ + && test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON_PACKAGE_SHA256}" \ + && apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p ${RUNTIME_ROOT}/bin \ && cd ${RUNTIME_ROOT} \ - && npm install --omit=dev --no-fund --no-audit \ - @noopolis/daimon@${DAIMON_VERSION} \ - @noopolis/mneme@${MNEME_VERSION} \ - @earendil-works/pi-coding-agent@${PI_VERSION} \ - @earendil-works/pi-ai@${PI_VERSION} \ + && npm install --omit=dev --no-fund --no-audit /tmp/daimon.tgz @openai/codex@${CODEX_CLI_VERSION} \ + && curl -fsSL "${GROK_CLI_URL}" -o /tmp/grok \ + && echo "${GROK_CLI_SHA256} /tmp/grok" | sha256sum -c - \ + && install -m 0755 /tmp/grok ${RUNTIME_ROOT}/bin/grok \ + && curl -fsSL "${AGY_CLI_URL}" -o /tmp/agy \ + && echo "${AGY_CLI_SHA256} /tmp/agy" | sha256sum -c - \ + && install -m 0755 /tmp/agy ${RUNTIME_ROOT}/bin/agy \ + && ln -s ../node_modules/.bin/codex ${RUNTIME_ROOT}/bin/codex \ + && ln -s ../node_modules/.bin/daimon-runtime ${RUNTIME_ROOT}/bin/daimon-runtime \ + && cp ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.json ${RUNTIME_ROOT}/contract-manifest.json \ + && cp ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.sha256 ${RUNTIME_ROOT}/contract-manifest.sha256 \ + && printf '%s' "${DAIMON_CAPABILITY_RECEIPT_BASE64}" | base64 -d > ${RUNTIME_ROOT}/capability-receipt.json \ + && expected_manifest="$(cat ${RUNTIME_ROOT}/contract-manifest.sha256)" \ + && test "${expected_manifest}" = "${DAIMON_MANIFEST_SHA256}" \ + && test "$(sha256sum ${RUNTIME_ROOT}/contract-manifest.json | awk '{print "sha256:" $1}')" = "${expected_manifest}" \ + && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),e={agy:["agy-auth",".daimon-inbound/agy-auth",".antigravity-cli/antigravity-oauth-token"],codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"],grok:["grok-auth",".daimon-inbound/grok-auth",".grok/auth.json"]}; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v1"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(Object.keys(e))||!m.engineCredentialMaterial||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;}))process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ + && node -e 'const fs=require("fs"); const r=JSON.parse(fs.readFileSync(process.argv[1])); const x={codex:process.argv[5],grok:process.argv[6],agy:process.argv[7]}; if(r.version!=="spawnfile.daimon-runtime-capability-receipt.v1"||r.architecture!==process.argv[2]||r.manifest_sha256!==process.argv[3]||r.daimon?.package_sha256!==process.argv[4]||r.daimon?.source_sha256!==process.argv[8]||Object.entries(x).some(([k,v])=>r.engines?.[k]?.executable_sha256!==v))process.exit(1)' ${RUNTIME_ROOT}/capability-receipt.json "${TARGETARCH}" "${expected_manifest}" "${DAIMON_PACKAGE_SHA256}" "${CODEX_CLI_SHA256}" "sha256:${GROK_CLI_SHA256#sha256:}" "sha256:${AGY_CLI_SHA256#sha256:}" "${DAIMON_SOURCE_SHA256}" \ + && test "$(sha256sum ${RUNTIME_ROOT}/bin/codex | awk '{print "sha256:" $1}')" = "${CODEX_CLI_SHA256}" \ + && test "$(sha256sum ${RUNTIME_ROOT}/bin/grok | awk '{print "sha256:" $1}')" = "sha256:${GROK_CLI_SHA256#sha256:}" \ + && test "$(sha256sum ${RUNTIME_ROOT}/bin/agy | awk '{print "sha256:" $1}')" = "sha256:${AGY_CLI_SHA256#sha256:}" \ && npm cache clean --force \ && test -f ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/package.json diff --git a/runtimes.yaml b/runtimes.yaml index 710353ee..4236c517 100644 --- a/runtimes.yaml +++ b/runtimes.yaml @@ -13,12 +13,14 @@ runtimes: daimon: remote: git@github.com:noopolis/daimon.git - ref: v0.1.2 + ref: v0.2.0 default_branch: main install: kind: container_image image: noopolis/spawnfile-runtime-daimon - tag: 0.1.2 + tag: 0.2.0 + digest: sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa + capability_receipt: sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7 status: active openclaw: diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1aade8ac..3c6cb06b 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -10,7 +10,8 @@ scripts/ ├── bootstrap-worktree.test.mjs # Bare node:test coverage and bootstrap self-test gate ├── build-closure.mjs # Builds a selected repository dependency closure in order ├── build-closure.test.mjs # Injected-registry and injected-run closure tests -├── build-local-moltnet.mjs # Builds and stamps a local Moltnet release through Docker +├── build-local-daimon-runtime.mjs # Builds a digest-bound generic local Daimon image +├── build-local-moltnet.mjs # Builds and stamps a local Moltnet release through Go ├── loop-verify.mjs # Runs mechanical loop gates and summarizes suite failures ├── loop-verify.test.mjs # Tests loop verification freshness and TAP parsing helpers ├── tap-self-test.mjs # Shared TAP parsing, test discovery, and case assertions diff --git a/scripts/build-local-daimon-runtime.mjs b/scripts/build-local-daimon-runtime.mjs new file mode 100644 index 00000000..e0343f0c --- /dev/null +++ b/scripts/build-local-daimon-runtime.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Builds a generic local Daimon image from a clean, packaged sibling source. +// Spawnfile never reads or carries engine credential contents. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { hashTrackedSourceEntries } from "./build-local-moltnet.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const configuredDaimonSource = process.env.SPAWNFILE_DAIMON_SOURCE_DIR?.trim(); +const daimonDir = configuredDaimonSource + ? path.resolve(configuredDaimonSource) + : path.resolve(repoRoot, "..", "daimon"); +const sha256 = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const digest = /^[a-f0-9]{64}$/u; + +const requiredDigest = (name) => { + const value = process.env[name]?.trim(); + if (!value || !digest.test(value.replace(/^sha256:/u, ""))) throw new Error(`${name} must be a SHA-256 digest`); + return `sha256:${value.replace(/^sha256:/u, "")}`; +}; + +const requiredUrl = (name) => { + const value = process.env[name]?.trim(); + if (!value || !/^https:\/\//u.test(value)) throw new Error(`${name} must be an HTTPS URL`); + return value; +}; + +const trackedEntries = (root) => execFileSync("git", ["-C", root, "ls-files", "-s", "-z"], { encoding: "utf8" }) + .split("\0").filter(Boolean).map((entry) => { + const tab = entry.indexOf("\t"); + const [mode] = entry.slice(0, tab).split(" "); + return { mode, path: entry.slice(tab + 1) }; + }); + +const assertClean = (root) => { + const status = execFileSync("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], { encoding: "utf8" }); + if (status.trim()) throw new Error("Local Daimon image build requires a clean source tree"); +}; + +const stagePackagedDaimon = (directory) => { + const source = process.env.SPAWNFILE_DAIMON_PACKAGE_TARBALL?.trim(); + if (!source || !path.isAbsolute(source)) throw new Error("SPAWNFILE_DAIMON_PACKAGE_TARBALL must be an absolute packaged Daimon tarball"); + const entry = lstatSync(source); + if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0) throw new Error("SPAWNFILE_DAIMON_PACKAGE_TARBALL must be a nonempty regular file"); + const staged = path.join(directory, "daimon.tgz"); + copyFileSync(source, staged); + return staged; +}; + +export const createLocalDaimonCapabilityReceipt = ({ architecture, manifestSha256, packageSha256, sourceSha256 }) => ({ + architecture, + daimon: { package_sha256: packageSha256, source_sha256: sourceSha256 }, + engines: { + agy: { executable_sha256: requiredDigest("AGY_CLI_SHA256") }, + codex: { executable_sha256: requiredDigest("CODEX_CLI_SHA256") }, + grok: { executable_sha256: requiredDigest("GROK_CLI_SHA256") } + }, + manifest_sha256: manifestSha256, + provenance: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + version: "spawnfile.daimon-runtime-capability-receipt.v1" +}); + +const main = () => { + if (configuredDaimonSource && !path.isAbsolute(configuredDaimonSource)) { + throw new Error("SPAWNFILE_DAIMON_SOURCE_DIR must be absolute"); + } + if (!existsSync(path.join(daimonDir, ".git"))) throw new Error(`Missing sibling Daimon checkout: ${daimonDir}`); + assertClean(daimonDir); + const manifestPath = path.join(daimonDir, "dist", "runtime", "contract-manifest.json"); + if (!existsSync(manifestPath)) throw new Error("Local Daimon package must contain dist/runtime/contract-manifest.json"); + const imageTag = process.env.SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG?.trim(); + if (!imageTag || imageTag.includes("@") || imageTag.endsWith(":latest")) { + throw new Error("SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG must be an explicit non-latest local tag"); + } + const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null; + if (!architecture) throw new Error(`Unsupported local Daimon architecture: ${process.arch}`); + const packageDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-daimon-package-")); + try { + const packagePath = stagePackagedDaimon(packageDirectory); + const receipt = createLocalDaimonCapabilityReceipt({ + architecture, + manifestSha256: sha256(readFileSync(manifestPath)), + packageSha256: sha256(readFileSync(packagePath)), + sourceSha256: hashTrackedSourceEntries(daimonDir, trackedEntries(daimonDir)) + }); + const receiptBytes = Buffer.from(`${JSON.stringify(receipt)}\n`); + execFileSync("docker", ["build", "--platform", `linux/${architecture}`, "--build-context", `daimon_package=${packageDirectory}`, + "-f", path.join(repoRoot, "runtime-images", "daimon", "Dockerfile"), "-t", imageTag, + "--build-arg", `DAIMON_CAPABILITY_RECEIPT_BASE64=${receiptBytes.toString("base64")}`, + "--build-arg", `DAIMON_MANIFEST_SHA256=${receipt.manifest_sha256}`, + "--build-arg", `DAIMON_PACKAGE_SHA256=${receipt.daimon.package_sha256}`, + "--build-arg", `DAIMON_SOURCE_SHA256=${receipt.daimon.source_sha256}`, + "--build-arg", `CODEX_CLI_SHA256=${receipt.engines.codex.executable_sha256}`, + "--build-arg", `GROK_CLI_URL=${requiredUrl("GROK_CLI_URL")}`, + "--build-arg", `GROK_CLI_SHA256=${receipt.engines.grok.executable_sha256.slice("sha256:".length)}`, + "--build-arg", `AGY_CLI_URL=${requiredUrl("AGY_CLI_URL")}`, + "--build-arg", `AGY_CLI_SHA256=${receipt.engines.agy.executable_sha256.slice("sha256:".length)}`, + repoRoot + ], { stdio: "inherit" }); + const [imageConfigDigest, imageArchitecture] = execFileSync( + "docker", ["image", "inspect", "--format", "{{.Id}}\n{{.Architecture}}", imageTag], { encoding: "utf8" } + ).trim().split("\n"); + if (!/^sha256:[a-f0-9]{64}$/u.test(imageConfigDigest)) throw new Error("Docker did not return an immutable image config digest"); + if (imageArchitecture !== architecture) throw new Error("Docker image architecture does not match the selected local Daimon inputs"); + writeFileSync(path.join(repoRoot, ".local-daimon-runtime-identity.json"), `${JSON.stringify({ + capability_receipt_sha256: sha256(receiptBytes), development: receipt.provenance, + image_architecture: imageArchitecture, image_config_digest: imageConfigDigest, + image_reference: imageTag, manifest_sha256: receipt.manifest_sha256, + version: "spawnfile.local-daimon-runtime-identity.v1" + })}\n`); + process.stdout.write(`Built local-development Daimon image ${imageTag} (${imageConfigDigest})\n`); + } finally { + rmSync(packageDirectory, { force: true, recursive: true }); + } +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(); diff --git a/scripts/build-local-daimon-runtime.test.mjs b/scripts/build-local-daimon-runtime.test.mjs new file mode 100644 index 00000000..d4f58415 --- /dev/null +++ b/scripts/build-local-daimon-runtime.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createLocalDaimonCapabilityReceipt } from "./build-local-daimon-runtime.mjs"; + +test("local Daimon receipt binds all three engine identities and manifest digest", () => { + const previous = { AGY_CLI_SHA256: process.env.AGY_CLI_SHA256, CODEX_CLI_SHA256: process.env.CODEX_CLI_SHA256, GROK_CLI_SHA256: process.env.GROK_CLI_SHA256 }; + process.env.AGY_CLI_SHA256 = "a".repeat(64); + process.env.CODEX_CLI_SHA256 = "b".repeat(64); + process.env.GROK_CLI_SHA256 = "c".repeat(64); + const receipt = createLocalDaimonCapabilityReceipt({ architecture: "amd64", manifestSha256: `sha256:${"d".repeat(64)}`, packageSha256: `sha256:${"e".repeat(64)}`, sourceSha256: `sha256:${"f".repeat(64)}` }); + assert.deepEqual(Object.keys(receipt.engines).sort(), ["agy", "codex", "grok"]); + assert.equal(receipt.daimon.package_sha256, `sha256:${"e".repeat(64)}`); + assert.equal(receipt.provenance.mode, "local-development"); + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +}); + +test("local Daimon receipt rejects a missing engine digest", () => { + const previous = process.env.AGY_CLI_SHA256; + delete process.env.AGY_CLI_SHA256; + assert.throws(() => createLocalDaimonCapabilityReceipt({ + architecture: "amd64", manifestSha256: `sha256:${"d".repeat(64)}`, + packageSha256: `sha256:${"e".repeat(64)}`, sourceSha256: `sha256:${"f".repeat(64)}` + }), /AGY_CLI_SHA256/u); + if (previous === undefined) delete process.env.AGY_CLI_SHA256; + else process.env.AGY_CLI_SHA256 = previous; +}); diff --git a/scripts/build-local-moltnet.mjs b/scripts/build-local-moltnet.mjs index 39c938bd..fe9b87f3 100644 --- a/scripts/build-local-moltnet.mjs +++ b/scripts/build-local-moltnet.mjs @@ -1,196 +1,159 @@ #!/usr/bin/env node -// Builds this checkout's pi-supporting Moltnet CLI for the container's Linux -// target and packages it as `moltnet_linux_.tar.gz` into the stable -// local release dir `ecosystem/moltnet/dist/release`. -// -// Why this exists: development against an unreleased Moltnet checkout still -// needs a pinned, locally verified artifact. Standard compiles download the -// authority-pinned published release; this opt-in script produces an explicit -// local override and the e2e helper verifies its identity and content. -// -// The resulting dir is consumed by the compiler's existing -// `stageMoltnetBinaries` mechanism via `SPAWNFILE_MOLTNET_RELEASE_DIR` -// (src/compiler/moltnetBinaries.ts) — this script does not reinvent staging, -// it only produces the asset that mechanism expects. +// Produces an explicit local-development Moltnet archive. Production staging +// never consults this output or a sibling checkout. -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const moltnetDir = path.join(repoRoot, "ecosystem", "moltnet"); -const releaseDir = path.join(moltnetDir, "dist", "release"); -const releaseAuthority = JSON.parse(readFileSync( - path.join(repoRoot, "moltnet-releases.json"), - "utf8" -)); - -const goarchForHost = () => { - switch (process.arch) { - case "arm64": - return "arm64"; - case "x64": - return "amd64"; - default: - throw new Error(`Unsupported host architecture for local Moltnet build: ${process.arch}`); - } +const configuredMoltnetSource = process.env.SPAWNFILE_MOLTNET_SOURCE_DIR?.trim(); +const moltnetDir = configuredMoltnetSource + ? path.resolve(configuredMoltnetSource) + : path.resolve(repoRoot, "..", "moltnet"); +const releaseDir = path.join(moltnetDir, "dist", "spawnfile-local-release"); +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +export const goarchForHost = () => { + if (process.arch === "arm64") return "arm64"; + if (process.arch === "x64") return "amd64"; + throw new Error(`Unsupported host architecture for local Moltnet build: ${process.arch}`); }; -// The bridge validator lists supported runtime kinds here; `RuntimePi` is only -// present once the (currently unreleased) pi-bridge delivery commit is in the -// checkout being built. Refuse to stamp a tarball as pi-capable unless its -// own source actually supports it, so a stale pre-pi-bridge checkout can never -// mint a passing stamp. -const sourceSupportsPiBridge = () => { - const configPath = path.join(moltnetDir, "pkg", "bridgeconfig", "config.go"); - return readFileSync(configPath, "utf8").includes("RuntimePi"); +export const goarchForTarget = () => { + const requested = process.env.MOLTNET_TARGET_GOARCH; + if (requested === undefined) return goarchForHost(); + if (requested !== "amd64" && requested !== "arm64") throw new Error(`Unsupported MOLTNET_TARGET_GOARCH: ${requested}`); + return requested; }; -const gitDescribe = () => { - try { - return execFileSync("git", ["-C", moltnetDir, "describe", "--tags", "--always", "--dirty"], { - encoding: "utf8" - }).trim(); - } catch { - return "unknown"; +const normalizedTrackedPath = (root, relativePath) => { + if (!relativePath || path.isAbsolute(relativePath)) throw new Error("Tracked source path must be relative"); + const resolved = path.resolve(root, relativePath); + const relative = path.relative(root, resolved); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Tracked source path escapes its root: ${relativePath}`); } + return { relative: relative.split(path.sep).join("/"), resolved }; }; -const gitRevision = () => execFileSync( - "git", ["-C", moltnetDir, "rev-parse", "HEAD"], { encoding: "utf8" } -).trim(); - -const assertCleanPinnedSource = (version) => { - if (version === "unknown" || version.endsWith("-dirty")) { - throw new Error( - `Moltnet source identity must be an exact clean revision, received ${version}; refusing to mint a release stamp.` - ); +const containedSymlink = (root, linkPath) => { + const link = readlinkSync(linkPath); + if (!link || path.isAbsolute(link)) throw new Error(`Tracked symlink must be nonempty and relative: ${linkPath}`); + const target = path.resolve(path.dirname(linkPath), link); + const relative = path.relative(root, target); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Tracked symlink escapes its source root: ${linkPath}`); } + return { link, target: relative.split(path.sep).join("/") }; }; -const trustedAssetFor = (arch) => { - const keys = Object.keys(releaseAuthority).sort().join("\0"); - const assets = releaseAuthority.assets; - if (keys !== ["assets", "capabilities", "release_version", "source_revision", "version"].sort().join("\0") - || releaseAuthority.version !== "spawnfile.moltnet-release-authority.v1" - || !Array.isArray(releaseAuthority.capabilities) - || releaseAuthority.capabilities.length !== 1 - || releaseAuthority.capabilities[0] !== "pi-bridge" - || !Array.isArray(assets) - || assets.length !== 2) { - throw new Error("Trusted Moltnet release authority is invalid"); - } - const asset = assets.find((candidate) => candidate.architecture === arch); - if (!asset - || Object.keys(asset).sort().join("\0") !== ["architecture", "asset", "asset_sha256"].sort().join("\0") - || asset.asset !== `moltnet_linux_${arch}.tar.gz` - || typeof asset.asset_sha256 !== "string" - || !/^sha256:[a-f0-9]{64}$/u.test(asset.asset_sha256) - || typeof releaseAuthority.source_revision !== "string" - || !/^[a-f0-9]{40}$/u.test(releaseAuthority.source_revision) - || typeof releaseAuthority.release_version !== "string") { - throw new Error(`Trusted Moltnet release authority has no valid ${arch} asset`); +/** Hash Git-tracked entries without dereferencing links. The link text and + * in-tree target identity are both bound, so tracked CLAUDE.md symlinks are + * accepted deterministically but cannot introduce an out-of-tree read. */ +export const hashTrackedSourceEntries = (root, entries) => { + const digest = createHash("sha256"); + const ordered = [...entries].sort((left, right) => left.path.localeCompare(right.path)); + const trackedPaths = new Set(ordered.map((entry) => normalizedTrackedPath(root, entry.path).relative)); + for (const entry of ordered) { + const { relative, resolved } = normalizedTrackedPath(root, entry.path); + const stats = lstatSync(resolved); + if (entry.mode === "120000") { + if (!stats.isSymbolicLink()) throw new Error(`Tracked symlink changed type: ${relative}`); + const target = containedSymlink(root, resolved); + if (!trackedPaths.has(target.target)) { + throw new Error(`Tracked symlink target is not a tracked in-tree source entry: ${relative}`); + } + digest.update(`symlink\0${relative}\0${target.link}\0${target.target}\0`); + continue; + } + if ((entry.mode !== "100644" && entry.mode !== "100755") || !stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`Unsupported tracked source entry: ${relative}`); + } + digest.update(`file\0${entry.mode}\0${relative}\0`); + digest.update(readFileSync(resolved)); + digest.update("\0"); } - return asset; + return `sha256:${digest.digest("hex")}`; }; -// The build already cross-compiles through GOARCH, so a laptop can stage the -// release its remote target actually needs. The stamp is written per arch, so -// an override can never overwrite another architecture's capability marker. -const goarchForTarget = () => { - const requested = process.env.MOLTNET_TARGET_GOARCH; - if (requested === undefined) return goarchForHost(); - if (requested !== "amd64" && requested !== "arm64") { - throw new Error(`Unsupported MOLTNET_TARGET_GOARCH: ${requested}`); - } - return requested; +const readTrackedEntries = (root) => execFileSync("git", ["-C", root, "ls-files", "-s", "-z"], { encoding: "utf8" }) + .split("\0").filter(Boolean).map((entry) => { + const tab = entry.indexOf("\t"); + const [mode] = entry.slice(0, tab).split(" "); + if (!mode || tab < 0) throw new Error("Unable to parse tracked source entry"); + return { mode, path: entry.slice(tab + 1) }; + }); + +const assertCleanSource = (root) => { + const status = execFileSync("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], { encoding: "utf8" }); + if (status.trim()) throw new Error("Local Moltnet build requires a clean source tree"); }; -const main = () => { - const arch = goarchForTarget(); - const assetName = `moltnet_linux_${arch}.tar.gz`; - const stampName = `moltnet_release_stamp_${arch}.json`; - const assetPath = path.join(releaseDir, assetName); - const stampPath = path.join(releaseDir, stampName); - const trustedAsset = trustedAssetFor(arch); - - if (!sourceSupportsPiBridge()) { - throw new Error( - `ecosystem/moltnet checkout does not support the pi bridge (RuntimePi missing from pkg/bridgeconfig/config.go); ` + - "refusing to stage. Update the moltnet checkout to a revision with pi-bridge delivery first." - ); +const assertBuiltBinaryCapabilities = (binaryPath) => { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-capability-")); + try { + for (const kind of ["pi", "daimon"]) { + const configPath = path.join(temporaryDirectory, `${kind}.json`); + writeFileSync(configPath, JSON.stringify({ + version: "moltnet.node.v1", + moltnet: { base_url: "http://127.0.0.1:9", network_id: "capability" }, + attachments: [{ agent: { id: `${kind}-agent`, name: `${kind} agent` }, runtime: kind === "daimon" + ? { kind, control_url: "http://127.0.0.1:19700", token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" } + : { kind, control_url: "http://127.0.0.1:19690/agents/pi-agent/wake" } }] + })); + const result = spawnSync(binaryPath, ["node", configPath], { + encoding: "utf8", env: { ...process.env, SPAWNFILE_DAIMON_CONTROL_TOKEN: "local-capability-probe" }, timeout: 1_000 + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.error?.code === "ETIMEDOUT") continue; // parser accepted; the endpoint is deliberately unreachable. + if (/unsupported|only supported|required|invalid/i.test(output)) { + throw new Error(`Built Moltnet binary does not accept ${kind}-bridge: ${output.trim()}`); + } + if (result.status !== null && /connection refused|connect:|dial tcp|network is unreachable/i.test(output)) continue; + throw new Error(`Built Moltnet binary could not be probed for ${kind}-bridge`); + } + } finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); } +}; - mkdirSync(releaseDir, { recursive: true }); - - const version = gitDescribe(); - assertCleanPinnedSource(version); - const sourceRevision = gitRevision(); - if (version !== releaseAuthority.release_version - || sourceRevision !== releaseAuthority.source_revision) { - throw new Error( - `Moltnet checkout ${version}/${sourceRevision} does not match trusted authority ` + - `${releaseAuthority.release_version}/${releaseAuthority.source_revision}` - ); - } - const writeTrustedStamp = (sha256) => { - if (`sha256:${sha256}` !== trustedAsset.asset_sha256) { - throw new Error( - `Built Moltnet ${assetName} digest sha256:${sha256} does not match trusted authority ${trustedAsset.asset_sha256}` - ); - } - const stamp = { - arch, - asset: trustedAsset.asset, - built_at: new Date().toISOString(), - capabilities: [...releaseAuthority.capabilities], - pi_bridge: true, - sha256, - source_revision: releaseAuthority.source_revision, - stamp_version: "spawnfile.moltnet-release-stamp.v1", - version: releaseAuthority.release_version - }; - writeFileSync(stampPath, `${JSON.stringify(stamp, null, 2)}\n`); - }; - if (existsSync(assetPath)) { - const existingSha256 = createHash("sha256").update(readFileSync(assetPath)).digest("hex"); - if (`sha256:${existingSha256}` === trustedAsset.asset_sha256) { - writeTrustedStamp(existingSha256); - console.log(`Verified existing trusted ${assetPath}`); - console.log(`Stamped ${stampPath} from moltnet-releases.json (sha256=${existingSha256.slice(0, 12)}…)`); - return; - } +const main = () => { + if (configuredMoltnetSource && !path.isAbsolute(configuredMoltnetSource)) { + throw new Error("SPAWNFILE_MOLTNET_SOURCE_DIR must be absolute"); } - const workDir = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-build-")); + if (!existsSync(path.join(moltnetDir, ".git"))) throw new Error(`Missing sibling Moltnet checkout: ${moltnetDir}`); + assertCleanSource(moltnetDir); + const arch = goarchForTarget(); + if (arch !== goarchForHost()) throw new Error("Cross-compiled local archives cannot prove their binary capabilities on this host"); + const workDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-build-")); + const binaryPath = path.join(workDirectory, "moltnet"); + const asset = `moltnet_linux_${arch}.tar.gz`; + const assetPath = path.join(releaseDir, asset); try { - console.log(`Building local pi-supporting Moltnet (local Go, linux/${arch}) from ${moltnetDir}`); - execFileSync("go", [ - "build", "-trimpath", "-ldflags", `-s -w -X main.version=${version}`, - "-o", path.join(workDir, "moltnet"), "./cmd/moltnet" - ], { + execFileSync("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", binaryPath, "./cmd/moltnet"], { cwd: moltnetDir, - env: { - ...process.env, - CGO_ENABLED: "0", - GOARCH: arch, - GOOS: "linux", - GOTOOLCHAIN: "local" - }, + env: { ...process.env, CGO_ENABLED: "0", GOARCH: arch, GOOS: "linux", GOTOOLCHAIN: "local" }, stdio: "inherit" }); - execFileSync("tar", ["-C", workDir, "-czf", assetPath, "moltnet"], { stdio: "inherit" }); + assertBuiltBinaryCapabilities(binaryPath); + mkdirSync(releaseDir, { recursive: true }); + execFileSync("tar", ["-C", workDirectory, "-czf", assetPath, "moltnet"], { stdio: "inherit" }); } finally { - rmSync(workDir, { force: true, recursive: true }); + rmSync(workDirectory, { force: true, recursive: true }); } - - const sha256 = createHash("sha256").update(readFileSync(assetPath)).digest("hex"); - writeTrustedStamp(sha256); - - console.log(`Staged ${assetPath}`); - console.log(`Stamped ${stampPath} from moltnet-releases.json (version=${version} revision=${sourceRevision.slice(0, 12)} pi_bridge=true sha256=${sha256.slice(0, 12)}…)`); + const stamp = { + arch, asset, capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + sha256: sha256(readFileSync(assetPath)), + source_sha256: hashTrackedSourceEntries(moltnetDir, readTrackedEntries(moltnetDir)), + stamp_version: "spawnfile.local-moltnet-release-stamp.v1" + }; + writeFileSync(path.join(releaseDir, `local_moltnet_release_stamp_${arch}.json`), `${JSON.stringify(stamp)}\n`); + process.stdout.write(`Staged local-development ${asset} (${stamp.source_sha256})\n`); }; -main(); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(); diff --git a/scripts/build-local-moltnet.test.mjs b/scripts/build-local-moltnet.test.mjs new file mode 100644 index 00000000..29a64adc --- /dev/null +++ b/scripts/build-local-moltnet.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { hashTrackedSourceEntries } from "./build-local-moltnet.mjs"; + +test("source hashing accepts contained CLAUDE symlinks deterministically", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-hash-")); + writeFileSync(path.join(root, "AGENTS.md"), "# Guide\n"); + symlinkSync("AGENTS.md", path.join(root, "CLAUDE.md")); + const entries = [{ mode: "100644", path: "AGENTS.md" }, { mode: "120000", path: "CLAUDE.md" }]; + assert.equal(hashTrackedSourceEntries(root, entries), hashTrackedSourceEntries(root, [...entries].reverse())); +}); + +test("source hashing rejects escaping symlinks", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-hash-")); + symlinkSync("../outside", path.join(root, "CLAUDE.md")); + assert.throws(() => hashTrackedSourceEntries(root, [{ mode: "120000", path: "CLAUDE.md" }]), /escapes/); +}); + +test("source hashing rejects contained symlinks to untracked content", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-hash-")); + writeFileSync(path.join(root, "private.md"), "not tracked\n"); + symlinkSync("private.md", path.join(root, "CLAUDE.md")); + assert.throws(() => hashTrackedSourceEntries(root, [{ mode: "120000", path: "CLAUDE.md" }]), /not a tracked/u); +}); diff --git a/scripts/verify-package-closure.mjs b/scripts/verify-package-closure.mjs index 30db739c..d4d9e340 100644 --- a/scripts/verify-package-closure.mjs +++ b/scripts/verify-package-closure.mjs @@ -86,6 +86,9 @@ const assertSourceClosure = async (manifest, lock) => { }; const assertPackedManifest = (manifest) => { + if (typeof manifest.name !== "string" || typeof manifest.version !== "string") { + fail("packed manifest lacks an exact package identity"); + } if (manifest.dependencies?.[STELE] !== STELE_VERSION) { fail(`packed ${STELE} coordinate drifted from ${STELE_VERSION}`); } @@ -123,6 +126,25 @@ const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { ], installRoot); const installedRoot = path.join(installRoot, "node_modules", manifest.name); if ((await lstat(installedRoot)).isSymbolicLink()) fail(`${manifest.name} installed as a source link`); + const installedManifest = await readJson(path.join(installedRoot, "package.json")); + if (installedManifest.name !== manifest.name || installedManifest.version !== manifest.version) { + fail("isolated install package identity drifted from the packed manifest"); + } + const helperProgram = path.join(installedRoot, "dist", "evidenceExportHelper", "helperProgram.mjs"); + const helperMetadata = await lstat(helperProgram); + if (!helperMetadata.isFile() || helperMetadata.isSymbolicLink() || helperMetadata.size < 1) { + fail("packed evidence helper asset is missing or unsafe"); + } + const helperRecipe = await import(pathToFileURL(path.join( + installedRoot, "dist", "evidenceExportHelper", "recipe.js", + )).href); + const helper = await helperRecipe.loadLocalEvidenceHelperRecipe(); + const helperSource = await readFile(helperProgram, "utf8"); + if (!helperSource.startsWith("#!/usr/local/bin/node") + || !(helper.context instanceof Uint8Array) + || !/^sha256:[a-f0-9]{64}$/u.test(helper.recipeDigest)) { + fail("packed evidence helper asset cannot be imported into its recipe"); + } const moltnetBinaries = await import(pathToFileURL(path.join( installedRoot, "dist/compiler/moltnetBinaries.js", @@ -162,11 +184,25 @@ const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { const stele = await import(pathToFileURL(steleRealPath).href); if (typeof stele.parseCausalJsonl !== "function") fail(`${STELE} runtime import is incomplete`); const executable = path.join(installRoot, "node_modules", ".bin", Object.keys(manifest.bin ?? {})[0]); - await run(executable, ["--help"], installRoot, { + const executableEnvironment = { ...process.env, PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}`, - }); + }; + await run(executable, ["--help"], installRoot, executableEnvironment); + const capabilities = JSON.parse((await run( + executable, ["capabilities", "--json"], installRoot, executableEnvironment, + )).stdout); + if (capabilities?.version !== "spawnfile.capabilities.v1" + || capabilities?.implementation?.package !== manifest.name + || capabilities?.implementation?.version !== manifest.version + || capabilities?.capabilities?.composed_lifecycle?.complete !== true + || capabilities?.capabilities?.composed_lifecycle?.command_set_version + !== "spawnfile.composed-lifecycle-contract-set.v1") { + fail("packed CLI capability contract or package identity drifted"); + } return { + capabilitiesVersion: capabilities.version, + commandSetVersion: capabilities.capabilities.composed_lifecycle.command_set_version, moltnetReleases, steleResolved: path.relative(installRealRoot, steleRealPath), }; @@ -195,6 +231,16 @@ const main = async () => { fail("npm pack manifest integrity does not match the inspected tarball bytes"); } const entries = new Set(packed.files.map((entry) => entry.path)); + if (!entries.has("dist/evidenceExportHelper/helperProgram.mjs") + || !entries.has("dist/evidenceExportHelper/recipe.js")) { + fail("packed tarball omits the evidence helper runtime assets"); + } + if ([...entries].some((entry) => /\.test-helper\.(?:js|d\.ts)$/u.test(entry))) { + fail("packed tarball leaked a test helper"); + } + if ([...entries].some((entry) => /\/[^/]*RunOperatorInputs\.(?:js|d\.ts)$/u.test(entry))) { + fail("packed tarball leaked an unbound operator-input contract"); + } if ((packed.bundled?.length ?? 0) !== 0) fail("npm pack unexpectedly bundled dependencies"); if ([...entries].some((entry) => entry.startsWith("node_modules/") || entry.includes("ecosystem/") || /vendor\/.*\.tgz$/u.test(entry))) { @@ -204,9 +250,14 @@ const main = async () => { "tar", ["-xOf", tarballPath, "package/package.json"], packageRoot, )).stdout); assertPackedManifest(packedManifest); + if (packedManifest.name !== manifest.name || packedManifest.version !== manifest.version) { + fail("packed manifest identity drifted from the source manifest"); + } const installed = await assertInstalledClosure(installRoot, manifest, tarballPath); process.stdout.write(`${JSON.stringify({ bundled: packed.bundled ?? [], + capabilities_version: installed.capabilitiesVersion, + command_set_version: installed.commandSetVersion, entries: packed.entryCount, integrity: packed.integrity, package: packed.id, diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index 338bc546..6b91b250 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -132,22 +132,18 @@ Runtimes MAY provide a reusable artifact image that already contains their pinne The Daimon, OpenClaw, and PicoClaw adapters use published runtime artifact images by default. Generated Dockerfiles copy each runtime from `/opt/spawnfile/runtime-installs/` and skip runtime npm/archive installs during organization builds. -Current default images: +Current default images include a generic Daimon engine runtime selected by +immutable digest and capability receipt: ```text -noopolis/spawnfile-runtime-daimon:0.1.2 +noopolis/spawnfile-runtime-daimon@sha256: noopolis/spawnfile-runtime-openclaw:2026.6.11 noopolis/spawnfile-runtime-picoclaw:0.3.1 ``` -To test a local Daimon runtime artifact instead: - -```bash -git clone git@github.com:noopolis/daimon.git -cd daimon -npm run image:runtime:local -SPAWNFILE_DAIMON_RUNTIME_IMAGE=noopolis/spawnfile-runtime-daimon:0.1.2-local spawnfile up ./org --detach -``` +Public Daimon hosts do not accept a local/tag-only image override. Recovery and +development use the separately released source-free generic image with the +same pinned digest and capability receipt; mutable checkout images fail closed. OpenClaw and PicoClaw have equivalent overrides: @@ -274,8 +270,11 @@ Container startup must support Moltnet server and node artifacts emitted from `t its SHA-256 digest before extraction. `SPAWNFILE_MOLTNET_RELEASE_DIR` is an explicit offline/development override containing `moltnet_linux_.tar.gz` plus `moltnet_release_stamp_.json`. The - strict local stamp binds the asset digest and source revision and asserts the - `pi-bridge` capability, but is not itself a trust root. Both paths must match + strict local-development stamp binds the asset digest and source digest and + asserts the ordered `daimon-bridge`, `pi-bridge` capability set. It is usable + only with `SPAWNFILE_ALLOW_LOCAL_E2E=1`; production compiles accept only the + pinned public `pi-bridge` identity. Both paths verify the built archive rather + than trusting a source declaration. the same checked-in authority; a self-authored matching stamp/tarball pair and every unpinned `latest` coordinate are rejected. - `server.store.kind: sqlite` and `server.store.kind: json` create the configured or default store directory before server start. @@ -321,9 +320,9 @@ A failed detached start MUST NOT write a record. Redeploying the same deployment by default. `spawnfile dev apply --agent ` reads that record to find the running Docker target and container, recompiles into `.spawn-dev` without removing records, and mutates the running development container in place. The -v0.1 hot-apply path is Pi-only: it copies the refreshed Pi app config, the +v0.1 hot-apply path is `runtime: pi`-only: it copies the refreshed generated Pi app config, the selected agent workspace, every matching Moltnet node config, and managed -Moltnet server configs into the container, then calls the generated Daimon control +Moltnet server configs into the container, then calls the generated Pi control endpoint to load or reload that agent. New-agent Moltnet nodes are started as that agent is applied. Existing agents and the container are not restarted. Running managed Moltnet servers keep their current in-memory room membership @@ -525,8 +524,8 @@ spawnfile dev activity test/fixtures/e2e/daimon-org --agent new-agent --deployme ``` Dev mode uses `.spawn-dev` by default and keeps the deployment record there. -`dev apply` is intentionally source-backed and Pi-specific in v0.1. It does not -rebuild the image or restart the container; it updates one generated Daimon agent in +`dev apply` is intentionally source-backed and `runtime: pi`-specific in v0.1. It does not +rebuild the image or restart the container; it updates one generated Pi agent in the running container and starts that agent's Moltnet bridges only when the agent is new. diff --git a/specs/DISTRIBUTION.md b/specs/DISTRIBUTION.md index 1dde5604..9e24f950 100644 --- a/specs/DISTRIBUTION.md +++ b/specs/DISTRIBUTION.md @@ -71,7 +71,7 @@ A directory wins over a same-spelled ref unless `--image` is set. Image-mode `ru ## Consumer Flow -Image-mode `up` is always detached (it records a deployment and returns), so `--detach` is optional for an image reference. `spawnfile up ` never compiles or builds. It: +Image-mode `up` is always detached (it records a deployment and returns), so `--detach` is optional for an image reference. `spawnfile up ` never compiles or builds. It is an operator-oriented deployment command: `--json` is deliberately unsupported because the durable, correlated machine lifecycle contract is project-mode only. It: 1. Pulls the image if needed (`--pull` forces a refresh). 2. Inspects labels and verifies `com.spawnfile.image_contract`. diff --git a/specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md b/specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md index 8e152148..5dfc1ee2 100644 --- a/specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md +++ b/specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md @@ -55,8 +55,10 @@ do not change that ABI; a live decision-claim path requires the exact The first-tick receipt MUST prove tick 1 followed activation without a participant action. Organization readiness MUST bind a pinned `spawnfile.moltnet-release-identity.v1`—architecture, asset digest, release -version, source revision, and the sole `pi-bridge` capability—rather than an -unpinned `latest` input. +identity, and only the bridge capabilities its built binary proves—rather than +an unpinned `latest` input. The public identity remains solely `pi-bridge`; +the ordered dual capability identity is local-development-only and requires an +explicit compiler opt-in. `--lockstep`, if introduced, is restricted to an explicit local scripted diagnostic and is ineligible for live-agent evidence. It MUST NOT make a diff --git a/specs/RUNTIMES.md b/specs/RUNTIMES.md index d5d05b04..17eedfd5 100644 --- a/specs/RUNTIMES.md +++ b/specs/RUNTIMES.md @@ -70,10 +70,10 @@ The active v0.1 adapters are: | Runtime | Install Strategy | Adapter Shape | |---------|------------------|---------------| -| `daimon` | Runtime artifact image | Noopolis-native generated harness app backed by Pi | +| `daimon` | Immutable generic runtime image | One public `noopolis.daimon.organization-runtime.v1` host for up to 32 agents | | `openclaw` | Runtime artifact image | Runtime-native gateway and workspace config | | `picoclaw` | Runtime artifact image | Runtime-native gateway and workspace config | -| `pi` | npm package | Compatibility alias for the Daimon generated app path | +| `pi` | npm package | Legacy Spawnfile-generated Pi application path | ### Active Runtime Capability Matrix @@ -88,28 +88,28 @@ Support levels: | Spawnfile feature | OpenClaw | PicoClaw | Daimon | |-------------------|----------|----------|------------| -| Adapter shape | One gateway target per agent | One gateway target per agent | One generated app target for all Daimon agents in the compile graph | +| Adapter shape | One gateway target per agent | One gateway target per agent | One public Daimon organization host target for all Daimon agents (maximum 32) | | `workspace.docs` | Supported as role files under the runtime workspace | Supported as role files under the runtime workspace | Supported per concrete agent workspace, plus a harness-owned operating contract | | `workspace.skills` | Supported under `workspace/skills` | Supported under `workspace/skills` | Supported per concrete agent workspace | | `workspace.resources` `volume` | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory per concrete agent workspace | | `workspace.resources` `git` | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | | `environment.env`, `environment.secrets`, `environment.packages` | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | -| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Degraded; not lowered into the generated Daimon app yet | -| `memory` | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported through Mneme; `engine: pi` uses in-process tools and CLI engines receive pre-turn recall context only | -| `execution.sandbox.mode` | Supported through OpenClaw runtime/container workspace behavior | Supported through `restrict_to_workspace` and container workspace behavior | Degraded; container/workspace isolation only, Pi itself is not a sandbox engine | -| `subagents` | Degraded; routed sessions do not preserve full parent-owned semantics | Supported through PicoClaw subagent behavior | Degraded; grouped app agents do not preserve parent-owned subagent semantics | +| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Rejected in Phase A; the public organization config has no MCP field | +| `memory` | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Degraded/declared only in Phase A; no Spawnfile memory lowering enters the public config | +| `execution.sandbox.mode` | Supported through OpenClaw runtime/container workspace behavior | Supported through `restrict_to_workspace` and container workspace behavior | Degraded; the generic runtime image and physical roots provide isolation | +| `subagents` | Degraded; routed sessions do not preserve full parent-owned semantics | Supported through PicoClaw subagent behavior | Degraded; the public host runs listed agents independently | #### Model, Schedule, And Surface Support | Spawnfile feature | OpenClaw | PicoClaw | Daimon | |-------------------|----------|----------|------------| -| OpenAI `api_key` / `codex` auth | Supported | Supported | Supported | -| Anthropic `api_key` auth | Supported | Supported | Supported | -| Anthropic `claude-code` auth | Supported | Supported | Supported through Pi's Anthropic OAuth auth store | -| `custom` or `local` endpoint | Supported except subscription-import auth | Supported for compatible endpoint/auth pairs | Supported for `api_key` and `none` auth through generated Pi `models.json` | -| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Degraded | -| `schedule.kind: every` | Degraded | Degraded | Supported by the generated app scheduler | -| `surfaces.moltnet` | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge and Daimon control endpoint | +| OpenAI `api_key` / `codex` auth | Supported | Supported | Only optional OpenAI Codex subscription intent; auth stays Daimon-owned | +| Anthropic `api_key` auth | Supported | Supported | Rejected | +| Anthropic `claude-code` auth | Supported | Supported | Rejected | +| `custom` or `local` endpoint | Supported except subscription-import auth | Supported for compatible endpoint/auth pairs | Rejected | +| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Rejected in Phase A | +| `schedule.kind: every` | Degraded | Degraded | Rejected in Phase A | +| `surfaces.moltnet` | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge | Supported through Daimon's authenticated public `/v1/wake` control bridge when the selected Moltnet release declares `daimon-bridge`; a public pi-only release fails closed | | Discord, Telegram, WhatsApp, Slack | Supported with OpenClaw access-mode coverage | Partial: open and user allowlists; pairing and richer allowlists rejected | Rejected | | Webhook | Parsed, not lowered by active adapters in v0.1 | Parsed, not lowered by active adapters in v0.1 | Rejected | @@ -119,8 +119,8 @@ Support levels: |-------------------|----------|----------|------------| | `spawnfile compile`, `build`, `run`, `up` | Supported | Supported | Supported | | `spawnfile status --live` runtime probes | Supported | Supported | Limited; runtime health probes are not implemented yet | -| Runtime activity stream | Not normalized yet | Not normalized yet | Supported through `spawnfile.activity.v1` buffer and SSE endpoint | -| `spawnfile dev apply --agent` hot-add | Not supported in v0.1 | Not supported in v0.1 | Supported for Daimon app agents and their Moltnet bridge | +| Runtime activity stream | Not normalized yet | Not normalized yet | Limited; Daimon exposes its public activity API but Spawnfile has no adapter probe yet | +| `spawnfile dev apply --agent` hot-add | Not supported in v0.1 | Not supported in v0.1 | Not supported in Phase A | | Managed Moltnet servers and durable Moltnet state | Compiler-owned and runtime-independent | Compiler-owned and runtime-independent | Compiler-owned and runtime-independent | --- @@ -133,16 +133,16 @@ persisted, searchable, and policy-enforced. | Dimension | OpenClaw | PicoClaw | Daimon | |-----------|----------|----------|--------| -| Store lowering (`sqlite`, `json`) | Supported through generated Mneme MCP servers | Supported through generated Mneme MCP servers | Supported through the generated Daimon app and Mneme runtime | +| Store lowering (`sqlite`, `json`) | Supported through generated Mneme MCP servers | Supported through generated Mneme MCP servers | Declared only in Phase A; no memory tool is lowered | | Store lowering (`postgres`) | Reported by DSN secret name only; runtime tools are not wired in v0.1 | Reported by DSN secret name only; generated Mneme MCP is not emitted in v0.1 | Reported by DSN secret name only; runtime tools are not wired in v0.1 | | Durable persistent mounts | Compiler-owned | Compiler-owned | Compiler-owned | -| Tool coverage (`search`, `locate`, `register`, `summarize`, `forget`) | Supported through generated Mneme MCP for file stores in awake and dream modes | Supported through generated Mneme MCP for file stores in awake and dream modes | Supported directly for `engine: pi`; CLI engines receive prepared recall but not callable Mneme tools | -| Principal/scope enforcement | Enforced by Mneme MCP context generated from runtime config | Enforced by Mneme MCP context generated from runtime config | Enforced by generated Daimon memory context before each turn | +| Tool coverage (`search`, `locate`, `register`, `summarize`, `forget`) | Supported through generated Mneme MCP for file stores in awake and dream modes | Supported through generated PicoClaw MCP for file stores in awake and dream modes | Not lowered in Phase A | +| Principal/scope enforcement | Enforced by Mneme MCP context generated from runtime config | Enforced by Mneme MCP context generated from runtime config | Not lowered in Phase A | | Lexical index | Reported | Supported/default through Mneme | Supported/default through Mneme | -| Vector index | Supported for generated Mneme MCP when `provider: ollama` is configured | Supported for generated Mneme MCP when `provider: ollama` is configured | Supported for generated Daimon memory when `provider: ollama` is configured | +| Vector index | Supported for generated Mneme MCP when `provider: ollama` is configured | Supported for generated Mneme MCP when `provider: ollama` is configured | Declared only in Phase A | | Graph/temporal index | Optional/degraded unless configured | Optional/degraded unless configured | Optional/degraded unless configured | -| Scheduled consolidation / dream mode | Supported through generated isolated OpenClaw cron jobs and dream-mode Mneme MCP for file stores | Supported through generated PicoClaw cron jobs and dream-mode Mneme MCP for file stores | Supported for `every` schedules as fresh one-off dream wakes for `engine: pi`; cron-like schedules are degraded | -| Activity/audit events | Memory events are recorded by Mneme tools; runtime activity normalization is still runtime-owned | Memory events are recorded by Mneme tools; runtime activity normalization is still runtime-owned | Memory events are recorded by Mneme tools and runtime activity is exposed through `spawnfile.activity.v1` | +| Scheduled consolidation / dream mode | Supported through generated isolated OpenClaw cron jobs and dream-mode Mneme MCP for file stores | Supported through generated PicoClaw cron jobs and dream-mode Mneme MCP for file stores | Not lowered in Phase A | +| Activity/audit events | Memory events are recorded by Mneme tools; runtime activity normalization is still runtime-owned | Memory events are recorded by Mneme tools; runtime activity normalization is still runtime-owned | Daimon host activity only; no Spawnfile memory activity lowering | | Raw memory visibility to runtime files | Must be denied | Must be denied | Must be denied | If a runtime exposes a live memory tool but cannot preserve scope/principal @@ -151,6 +151,28 @@ may report declared memory as `degraded` when it emits no live memory tool and keeps the bank report-only. If it can preserve storage but not a requested index or consolidation mode, that specific capability is `degraded`. +`runtime: pi` remains the separate legacy generated-Pi implementation. Its +generated engine, auth, scheduler, MCP, and Moltnet behavior is not part of +the `runtime: daimon` public-host contract and must not be inferred from it. + +### Daimon opaque auth ownership + +Daimon credential inputs are opaque local bind sources, not Spawnfile auth +files. Spawnfile authorizes their filesystem metadata only and passes a +nonzero common owner UID through the in-memory launch path; it never reads or +interprets credential bytes and never creates an engine home. The generated +container wrapper uses that UID only to prepare compiler-owned writable state, +then Daimon materializes its own private engine artifact after privilege drop. +Remote, SSH, and user-namespace-remapped Docker targets are unsupported when +an opaque Daimon source is present. + +The consumed Daimon manifest declares opaque file slots for Codex and Grok. +For AGY it declares one host-realm durable mount plus one independent opaque +unlock source slot. Spawnfile emits the stable RW volume, metadata-authorizes +the caller-owned `0600` unlock source, and mounts it read-only; it never reads +either OAuth or unlock bytes and never starts D-Bus or AGY. Daimon alone owns +the Linux Secret Service lifecycle and interactive subscription enrollment. + --- ## Version Pinning diff --git a/specs/SPEC.md b/specs/SPEC.md index 6a9da4fa..86f94228 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -514,14 +514,15 @@ Daimon accepts an optional runtime engine selector: runtime: name: daimon options: - engine: pi + engine: codex ``` -For `runtime.name: daimon`, `runtime.options.engine` MAY be `pi`, `codex`, -`claude`, `grok`, or `agy`. If omitted, the compiler MUST use `pi`. `engine: -pi` runs the in-process Daimon/Pi harness. The other values run the generated -Daimon CLI-engine wrapper for that tool while keeping Spawnfile workspace, -Moltnet, schedule, and Mneme memory wiring in the generated Daimon app. +For `runtime.name: daimon`, `runtime.options.engine` MAY be `codex`, `grok`, +or `agy`. If omitted, the compiler MUST use `codex`. Spawnfile emits one strict +`noopolis.daimon.organization-runtime.v1` host config and never generates engine +argv, auth, or Pi code for it. In Phase A, schedules, MCP declarations, and all +agent surfaces MUST be rejected. `runtime: pi` is the separate legacy generated +Pi implementation and retains its own engine/auth/scheduler/MCP/Moltnet behavior. ### 2.5 Execution Intent @@ -1378,7 +1379,10 @@ Rules: - When the root team omits `external_participants`, compilation MUST preserve the standalone organization and Moltnet path: it MUST NOT require the topology operator/actor-token split, synthesize a service identity, or emit an external - participant artifact. + participant artifact. The compiler still MUST derive canonical agent-member + organization identity from the valid root-team graph, with an empty resolved + external-participant list, for generic organization-bound inputs such as world + bindings. ### 4.7 Team Docs And Context Artifacts @@ -1956,13 +1960,13 @@ from normal `.spawn/` output and defaults to `.spawn-dev/`. - MUST target a project-backed dev deployment record - MUST recompile source into the dev output directory without deleting the deployment record - MUST hot-apply exactly one agent selected by id, slug, or name -- For Daimon runtime agents, MUST copy the updated Daimon app config, selected agent workspace, every matching Moltnet node config, and managed Moltnet server configs into the recorded running container, then call the generated Daimon control endpoint to load the agent +- For Pi runtime agents, MUST copy the updated generated Pi app config, selected agent workspace, every matching Moltnet node config, and managed Moltnet server configs into the recorded running container, then call the generated Pi control endpoint to load the agent - For a new Pi agent with Moltnet node configs, MUST start only that agent's Moltnet node processes; existing agents and the container MUST NOT be restarted - For an existing Pi agent, MUST reload the in-memory Pi agent and MUST NOT start a duplicate Moltnet node - Running managed Moltnet servers keep their current in-memory room membership until the copied server config is reconciled through an operator-token `moltnet apply` or a server restart - MUST fail clearly for unsupported runtimes or deployments without a live Pi control endpoint -`spawnfile dev activity` reads the generated Daimon app's bounded +`spawnfile dev activity` reads the generated Pi app's bounded `spawnfile.activity.v1` buffer from the running dev container and prints JSON lines. It MAY filter by agent id, slug, or name, and MUST NOT read Moltnet message bodies or expose hidden reasoning. diff --git a/specs/SURFACES.md b/specs/SURFACES.md index b572deeb..93f2c21a 100644 --- a/specs/SURFACES.md +++ b/specs/SURFACES.md @@ -165,7 +165,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported Access | Notes | |---|---|---| -| `daimon` | rejected | Daimon only supports Moltnet surfaces in v0.1. | +| `daimon` | rejected | Phase A public Daimon hosts lower no agent surfaces. | | `openclaw` | `pairing`, `allowlist`, `open` | Supports user, guild, and channel policy lowering. Channel allowlists currently require exactly one guild in Spawnfile lowering. | | `picoclaw` | `open`, `allowlist` | Supports Discord token wiring and user allowlists. Guild/channel allowlists are not lowered in v0.1. | | `pi` | rejected | The generated Pi harness only supports Moltnet surfaces in v0.1. | @@ -174,7 +174,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported Access | Notes | |---|---|---| -| `daimon` | rejected | Daimon only supports Moltnet surfaces in v0.1. | +| `daimon` | rejected | Phase A public Daimon hosts lower no agent surfaces. | | `openclaw` | `pairing`, `allowlist`, `open` | Supports DM and group/chat policy lowering. | | `picoclaw` | `open`, `allowlist` | Supports Telegram token wiring and user allowlists. Chat allowlists are not lowered in v0.1. | | `pi` | rejected | The generated Pi harness only supports Moltnet surfaces in v0.1. | @@ -183,7 +183,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported Access | Notes | |---|---|---| -| `daimon` | rejected | Daimon only supports Moltnet surfaces in v0.1. | +| `daimon` | rejected | Phase A public Daimon hosts lower no agent surfaces. | | `openclaw` | `pairing`, `allowlist`, `open` | Supports DM and group policy lowering. | | `picoclaw` | `open`, `allowlist` | Supports user allowlists. Portable group allowlists are not lowered in Spawnfile v0.1. | | `pi` | rejected | The generated Pi harness only supports Moltnet surfaces in v0.1. | @@ -192,7 +192,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported Access | Notes | |---|---|---| -| `daimon` | rejected | Daimon only supports Moltnet surfaces in v0.1. | +| `daimon` | rejected | Phase A public Daimon hosts lower no agent surfaces. | | `openclaw` | `pairing`, `allowlist`, `open` | Requires both bot and app/socket tokens. Supports DM and channel policy lowering. | | `picoclaw` | `open`, `allowlist` | Requires both bot and app/socket tokens. Portable channel allowlists are not lowered in Spawnfile v0.1. | | `pi` | rejected | The generated Pi harness only supports Moltnet surfaces in v0.1. | @@ -201,7 +201,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported Shape | Notes | |---|---|---| -| `daimon` | supported | Lowers generated Moltnet client config, skill installation, persistent open-token directories, and `moltnet node` bridge configs that deliver wakes through the generated Daimon app control endpoint. | +| `daimon` | team-network attachments | Lowers MoltnetNode runtime `daimon` attachments to Daimon's authenticated public `/v1/wake` API. A release without `daimon-bridge` is rejected; the public pi-only release cannot be relabeled as dual-capability. | | `openclaw` | team-network attachments | Lowers generated Moltnet client config and skill installation when artifacts are available. | | `picoclaw` | team-network attachments | Lowers generated Moltnet client config and skill installation when artifacts are available. | | `pi` | supported | Lowers generated Moltnet client config, skill installation, persistent open-token directories, and `moltnet node` bridge configs that deliver wakes through the generated Pi app control endpoint. | @@ -210,7 +210,7 @@ The portable schema is broader than any single runtime. A conforming compiler va | Runtime | Supported | Notes | |---|---|---| -| `daimon` | rejected | Daimon only supports Moltnet surfaces in v0.1. | +| `daimon` | rejected | Phase A public Daimon hosts lower no agent surfaces. | | `openclaw` | not yet | Webhook delivery support is planned. | | `picoclaw` | not yet | Webhook delivery support is planned. | | `pi` | rejected | The generated Pi harness only supports Moltnet surfaces in v0.1. | diff --git a/specs/TARGETS.md b/specs/TARGETS.md index 669246e0..b8b83e39 100644 --- a/specs/TARGETS.md +++ b/specs/TARGETS.md @@ -76,6 +76,112 @@ attestation, so supported teardown cannot race the proof. ## Target CLI +### Capability discovery + +An automation client must query capabilities before using a versioned public +contract: + +```bash +spawnfile capabilities --json +``` + +This command is read-only: it reads only the packaged Spawnfile version, does +not consume standard input, does not write files, and does not contact Docker +or any provider. Success emits exactly one strict +`spawnfile.capabilities.v1` JSON document followed by a newline. Missing +`--json` fails with exit code 2 and emits no receipt. + +The receipt names the exact target-config resolver command and output/config +versions. It also carries the complete closed +`spawnfile.composed-lifecycle-contract-set.v1` command-and-contract inventory: +an automation client MUST require `complete: true`, the exact set version, and +every command row it intends to use before mutation. Each row has a canonical +`argv` form plus explicit `stdin_versions`, `request_versions`, +`receipt_versions`, `invocation_versions`, and `pending_versions`; empty lists +mean that role is intentionally unversioned or unused. Image-reference `up` is +not in that inventory: only project-mode `up --json` with a lifecycle +invocation has correlated, lookup-recoverable machine semantics. It also names +the self-identifying prepared-plan version. That v1 plan accepts exactly +`version`, `evidence_destination`, plus one `prepared_artifact_mapping`; +unknown keys are rejected. The credential +provisioning request supports an optional `model_engine_auth` member, so a +scripted organization need not provide model-engine auth. + +The receipt reports only shipped public capabilities. The current build ships +the Spawnfile-owned, target-local +`spawnfile.target-evidence-export-helper.prepared.v1` helper receipt. It is +prepared with `spawnfile helper prepare-evidence-export --context --json` +and selected by `target resolve_config --prepare-evidence-helper`; callers do +not provide an authority-file path. Its identity is the exact Docker image +config digest, so a classic local Docker engine need not expose a registry +manifest digest. Its +public-artifact snapshot query returns the typed +`spawnfile.target-public-artifact-snapshot.not-present.v1` result when the +declared terminal artifact does not yet exist. It reads that terminal artifact +with one atomic no-follow open from a dedicated public tmpfs mount; a symlink, +parent traversal, replacement failure, or any other read failure is permanent +and MUST NOT be translated to `not_present`. Capability discovery does not +prove that a target or auth preflight will succeed on every machine. A caller +must validate the exact receipt versions it requires before beginning mutations. + +### Local evidence-export helper + +Spawnfile owns the local-development helper source, canonical USTAR build +context, image construction, and private transaction authority. Provision it +only on an explicitly named local Docker context whose selected Node base image +is already present: + +```bash +spawnfile helper prepare-evidence-export \ + --context default \ + --json +``` + +The command accepts `--base-image`, `--docker-command`, and a bounded +`--timeout-ms` when their defaults are unsuitable. It never selects the +current context implicitly, never targets a remote endpoint, never pulls or +pushes, and runs the Docker build with networking disabled. The package-shipped +recipe creates the exact helper label, `/bin/spawnfile-export-helper` +entrypoint, `65534:65534` user, and exactly one nonsecret environment entry: +`PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`. Null, +duplicate, additional, or drifted image environment entries are rejected. The +helper emits the strict canonical USTAR output required by the evidence-export +contract. + +Before the first Docker mutation Spawnfile persists and fsyncs one deterministic +pending transaction under its private target-local state root. That record binds +the exact context endpoint, daemon projection, platform, base config digest, +recipe digest, and a private reservation. The build captures Docker's emitted +immutable config ID directly rather than adopting a mutable tag. Recovery only +re-attests that completed exact config ID or rebuilds it from the same packaged +recipe; a pending-only reservation never consults or overwrites a helper tag. +It never discovers resources by listing or name scan. A public result is only the canonical +`spawnfile.target-evidence-export-helper.prepared.v1` receipt with its opaque +handle and digest. The Docker image identity used by target lowering is the +locally accepted config digest; a registry or `RepoDigest` is not required. + +The resolver may prepare the same package-owned artifact as part of its target +setup path: + +```bash +spawnfile target resolve_config \ + --context default \ + --evidence-destination "$PWD/.spawn-local/evidence.tar" \ + --prepare-evidence-helper +``` + +The resolver and target lifecycle consume the opaque preparation internally; +no caller-managed authority-file path is accepted. + +The `target resolve_config` result contains `target_config_digest`, computed +over canonical JSON bytes of its strict `target_config` under +`spawnfile.target-config-digest.v1`. When and only when +`--prepare-evidence-helper` was requested on an explicitly selected local +context, it also contains `prepared_evidence_helper`; that opaque receipt is +the exact same value embedded as `target_config.preparedEvidenceHelper`. +No helper config identity, daemon projection, reservation detail, or authority +path is public. + The built Spawnfile CLI exposes fourteen mutating/selection verbs, four read-only target queries, one owner-only lifecycle release, one separate read-only journal lookup, and one aggregate preparation command: @@ -102,8 +208,8 @@ read-only journal lookup, and one aggregate preparation command: - `lookup_operation` - `prepare_composed_run` -Simfile uses one aggregate preparation command rather than choosing among the -low-level preparation verbs: +A composed-lifecycle consumer uses one aggregate preparation command rather +than choosing among the low-level preparation verbs: ```bash target-config-producer gpu-host \ @@ -133,76 +239,23 @@ path, inline JSON, environment-derived secret values, or request-relative file paths. Request validation happens before configuration is read or a target operation starts. -### Composed-run operator inputs - -The one-command Simfile path freezes these operator-owned inputs before any -target mutation: - -| Input | Contract | -|---|---| -| Target selector | nonsecret `gpu-host` default, overridable only as a bounded selector | -| Private target configuration | `target-config-producer gpu-host` writes the strict object to stdin; only literal `--config -` is accepted | -| Runtime auth | named local profile `simfile-live`; the name may be recorded, its values may not | -| Spawnfile binary | normal `PATH` discovery of the installed `spawnfile` executable | -| Moltnet binary | exact digest-bound release download by default, or a strict local `moltnet_release_stamp_.json` override; both must match `moltnet-releases.json`, and unpinned `latest` is forbidden | -| Run roots | one absolute operator-selected root with distinct `output/`, `evidence/`, `journal/`, and `cache/` children owned by the run id | -| Correlation | one run id plus exact request digests, idempotency keys, selected-target receipt, and verified Moltnet identity | - -The operator-input request is nonsecret and versioned. Its executable parser -and resolver live at the runtime-specific ownership boundary, outside the -project-neutral target modules. It is not a replacement for any target-resource -request and grants no target authority by itself: - -```json -{ - "version": "spawnfile.simfile-run-operator-input.v1", - "run_id": "run-example", - "target_selector": "gpu-host", - "target_config_transport": "stdin", - "auth_profile": "simfile-live", - "run_root": "/operator/simfile/runs/run-example", - "moltnet_release": { - "directory_transport": "operator-path", - "required_capability": "pi-bridge", - "stamp_version": "spawnfile.moltnet-release-stamp.v1" - } -} -``` - -The correlated preparation receipt exposes only safe identities. The target -config, its producer command, Moltnet directory, and auth values are absent: - -```json -{ - "version": "spawnfile.simfile-run-operator-receipt.v1", - "run_id": "run-example", - "target_selector": "gpu-host", - "selected_target": { - "version": "spawnfile.target-resource.selected-target.v1", - "handle": "opaque_exampletarget01", - "fingerprint": "sha256:00000000000000000000000000000000" - }, - "auth_profile": "simfile-live", - "roots_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "moltnet_release": { - "version": "spawnfile.moltnet-release-identity.v1", - "release_version": "v0.1.14-1-g0000000", - "source_revision": "0000000000000000000000000000000000000000", - "architecture": "amd64", - "asset": "moltnet_linux_amd64.tar.gz", - "asset_sha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "capabilities": ["pi-bridge"] - } -} -``` - -The zeros are documentation placeholders, never accepted provenance. A real -receipt is derived from the same-build artifact, strict stamp, and exact -checked-in Moltnet release authority. A self-authored stamp/tarball pair does -not establish trust. The receipt is invalid if any identity is missing, stale, -mismatched, `latest`, or not correlated to the resolved request, run roots, and -selected-target receipt. Simfile may persist the secret-free receipt but must -never persist or echo the producer's private configuration. +### Composed-lifecycle client inputs + +There is no product-specific operator-input request. A composed-lifecycle client +discovers the machine-readable command set with `spawnfile capabilities --json` +and uses only rows advertised by `spawnfile.composed-lifecycle-contract-set.v1`. +It owns its local run-root layout, local authentication policy, and any +runtime-specific asset selection. + +The public CLI carries only versioned public requests and receipts. Target +configuration remains the strict private stdin input accepted exclusively by +`--config -`; it is never copied into a request or receipt. A client that needs +a resolved target uses the advertised `target resolve_config` contract, whose +`spawnfile.target-config-resolution.v1` receipt binds the strict target-config +digest, context class, platform, and base-image identity. Lifecycle calls bind +their idempotency and recovery through their published invocation and receipt +versions. These generic contracts do not grant an external caller access to +private target configuration or local credential values. The optional `container_bundle_store_root` member selects a dedicated, mode-`0700`, current-uid-owned physical directory for immutable target-local diff --git a/src/AGENTS.md b/src/AGENTS.md index 521f626d..8f50bbea 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -9,6 +9,7 @@ src/ ├── auth/ # Local auth profile storage and auth import flows ├── cli/ # User-facing command parsing and terminal entrypoints ├── compiler/ # Graph resolution, compile planning, and artifact emission +├── evidenceExportHelper/ # Package-owned local evidence helper recipe and authority ├── filesystem/ # File IO and path utilities ├── manifest/ # Spawnfile schema parsing and validation ├── report/ # Diagnostics and compile report generation diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index ee395a44..874e7b4f 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -8,11 +8,16 @@ This folder owns user-facing command parsing and process exit behavior. src/cli/ ├── index.ts # Executable Node entrypoint ├── runCli.ts # Top-level Commander setup and shared CLI types +├── capabilitiesCommand.ts # Read-only public capability command registration +├── capabilitiesReceipt.ts # Strict Spawnfile capability receipt +├── composedLifecycleContractSet.ts # Closed machine command/contract inventory +├── evidenceExportHelperCommand.ts # Local helper construction command ├── compileBuildCommands.ts # `compile` and `build` command registration ├── lifecycleCommands.ts # Thin lifecycle/compile/build/run/publish/up/down registration composition ├── lifecyclePlanningCommands.ts # Durable lifecycle plan and lookup command registration ├── runPublishCommands.ts # `run` and `publish` command registration ├── upCommand.ts # Project/image `up` registration and machine-lifecycle receipt flow +├── upLifecycleRecovery.ts # Exact detached-container recovery for machine project `up` ├── statusCommand.ts # Status command orchestration and registration ├── statusCommandOptions.ts # Status option parsing, handler contracts, and output helpers ├── statusCommandLive.ts # Home-store and live-deployment status collection @@ -21,6 +26,8 @@ src/cli/ ├── surfaceCommands.ts # `spawnfile surface ...` command registration ├── artifactsCommands.ts # `spawnfile artifacts export` command registration ├── targetCommands.ts # `spawnfile target ...` command registration +├── targetEvidenceHelperResolution.ts # Target-local evidence helper request derivation +├── targetConfigPreparedPlan.ts # Strict private prepared-plan file transport ├── targetComposedPreparationCommand.ts # One aggregate composed-run preparation command ├── targetWorldReadinessCommand.ts # public world-only readiness query registration ├── targetWorldClockCommand.ts # public post-activation world-clock query registration diff --git a/src/cli/capabilitiesCommand.test.ts b/src/cli/capabilitiesCommand.test.ts new file mode 100644 index 00000000..4ade4907 --- /dev/null +++ b/src/cli/capabilitiesCommand.test.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; + +import { registerCapabilitiesCommand } from "./capabilitiesCommand.js"; + +describe("capabilities command", () => { + it("writes exactly one receipt", async () => { + const stdout: string[] = []; + const program = new Command().exitOverride(); + registerCapabilitiesCommand(program, { stderr: () => undefined, stdout: (value) => stdout.push(value) }, "0.1.17"); + await program.parseAsync(["capabilities", "--json"], { from: "user" }); + expect(JSON.parse(stdout[0]!)).toMatchObject({ + implementation: { version: "0.1.17" }, version: "spawnfile.capabilities.v1", + }); + }); +}); diff --git a/src/cli/capabilitiesCommand.ts b/src/cli/capabilitiesCommand.ts new file mode 100644 index 00000000..753f124c --- /dev/null +++ b/src/cli/capabilitiesCommand.ts @@ -0,0 +1,23 @@ +import type { Command } from "commander"; + +import { SpawnfileError } from "../shared/index.js"; + +import { + createCapabilitiesReceipt, + createCapabilitiesReceiptBytes, +} from "./capabilitiesReceipt.js"; +import type { CliStreams } from "./runCli.js"; + +export const registerCapabilitiesCommand = ( + program: Command, + streams: CliStreams, + packageVersion: string, +): void => { + program.command("capabilities") + .description("Report supported public CLI contracts") + .requiredOption("--json", "Emit one strict versioned JSON receipt") + .action((options: { readonly json?: boolean }) => { + if (options.json !== true) throw new SpawnfileError("validation_error", "`capabilities` requires --json"); + streams.stdout(createCapabilitiesReceiptBytes(createCapabilitiesReceipt(packageVersion))); + }); +}; diff --git a/src/cli/capabilitiesReceipt.test.ts b/src/cli/capabilitiesReceipt.test.ts new file mode 100644 index 00000000..d4fa380a --- /dev/null +++ b/src/cli/capabilitiesReceipt.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + CAPABILITIES_RECEIPT_VERSION, + createCapabilitiesReceipt, + createCapabilitiesReceiptBytes, +} from "./capabilitiesReceipt.js"; + +describe("capabilities receipt", () => { + it("reports generic public contracts with their exact versions", () => { + const receipt = createCapabilitiesReceipt("0.1.17"); + expect(receipt.version).toBe(CAPABILITIES_RECEIPT_VERSION); + expect(receipt.implementation).toEqual({ cli: "spawnfile", package: "spawnfile", version: "0.1.17" }); + expect(receipt.capabilities.optional_model_auth.required).toBe(false); + expect(receipt.capabilities.evidence_export_helper).toEqual({ + identity: "docker-image-config-digest", + local_context_only: true, + prepare_command: ["helper", "prepare-evidence-export", "--context", "", "--json"], + receipt_version: "spawnfile.target-evidence-export-helper.prepared.v1", + resolver_option: "--prepare-evidence-helper", + provisioning: "spawnfile-owned-target-local", + }); + expect(receipt.capabilities.composed_lifecycle).toMatchObject({ + command_set_version: "spawnfile.composed-lifecycle-contract-set.v1", + complete: true, + }); + const commands = receipt.capabilities.composed_lifecycle.commands; + const find = (argv: readonly string[]) => commands.find((command) => + JSON.stringify(command.argv) === JSON.stringify(argv)); + expect(commands).toHaveLength(43); + for (const command of commands) { + expect(command).toEqual(expect.objectContaining({ argv: expect.any(Array), stdout: expect.any(String) })); + expect(command.invocation_versions).toEqual(expect.any(Array)); + expect(command.pending_versions).toEqual(expect.any(Array)); + expect(command.receipt_versions).toEqual(expect.any(Array)); + expect(command.request_versions).toEqual(expect.any(Array)); + expect(command.stdin_versions).toEqual(expect.any(Array)); + } + expect(find(["target", "--config", "-", "activate_topology", ""])) + .toMatchObject({ + request_versions: ["spawnfile.target-topology-attestation.request.v1"], + receipt_versions: ["spawnfile.target-topology-activation-receipt.v1"], + stdin_versions: ["spawnfile.target-default-config.v1"], + }); + expect(find(["target", "--config", "-", "lookup_operation", ""])) + .toMatchObject({ + pending_versions: ["spawnfile.target-resource.operation-lookup.v1"], + receipt_versions: ["spawnfile.target-resource.operation-lookup.v1"], + stdin_versions: ["spawnfile.target-lookup-config.v1"], + }); + expect(find(["auth", "provision", ""])).toMatchObject({ + receipt_versions: ["spawnfile.auth.credential-provisioning.receipt.v1"], + request_versions: [ + "spawnfile.auth.credential-provisioning.request.v1", + "spawnfile.auth.resolved-world-grants.v1", + ], + }); + expect(find(["auth", "target-secret", "revoke-grant", ""])) + .toMatchObject({ request_versions: ["spawnfile.auth.target-secret.source-request.v1"] }); + expect(find(["target", "--config", "-", "export_evidence_volume", ""])) + .toMatchObject({ + receipt_versions: [ + "spawnfile.target-resource.receipt.v1", + "spawnfile.target-resource.export-index.v1", + ], + }); + expect(find(["lifecycle", "plan", "--request", ""])).toMatchObject({ + request_versions: ["spawnfile.lifecycle-plan-request.v1"], + receipt_versions: ["spawnfile.lifecycle-invocation.v1"], + }); + expect(find([ + "up", "", "--detach", "--deployment", "", "--json", + "--lifecycle-invocation", "", "--organization-handoff-run-id", "", + "--descriptor-digest", "", "--selected-target-receipt", "", + "--selected-target-receipt-digest", "", "--network-attachment-handle", "", + "--world-bindings", "", + ])).toMatchObject({ + invocation_versions: ["spawnfile.lifecycle-invocation.v1"], + pending_versions: ["spawnfile.lifecycle-lookup.v1"], + receipt_versions: ["spawnfile.up-receipt.v1"], + }); + expect(receipt.capabilities.composed_lifecycle.commands).not.toContainEqual( + expect.objectContaining({ argv: ["up", "--image", "--json"] }) + ); + expect(receipt.capabilities.terminal_public_artifact.not_present_version) + .toBe("spawnfile.target-public-artifact-snapshot.not-present.v1"); + expect(receipt.capabilities.target_config_resolver.target_config_digest_version) + .toBe("spawnfile.target-config-digest.v1"); + expect(JSON.parse(createCapabilitiesReceiptBytes(receipt))).toEqual(receipt); + expect(Object.isFrozen(receipt.capabilities.target_config_resolver)).toBe(true); + }); + + it("rejects a non-package version", () => { + expect(() => createCapabilitiesReceipt("development")).toThrow("Invalid Spawnfile package version"); + }); +}); diff --git a/src/cli/capabilitiesReceipt.ts b/src/cli/capabilitiesReceipt.ts new file mode 100644 index 00000000..c4cd0e60 --- /dev/null +++ b/src/cli/capabilitiesReceipt.ts @@ -0,0 +1,118 @@ +import { CREDENTIAL_PROVISIONING_REQUEST_VERSION } from "../auth/index.js"; +import { PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION } from "../evidenceExportHelper/index.js"; +import { + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION, + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION, + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION, +} from "../target/publicArtifactSnapshot.js"; + +import { + COMPOSED_LIFECYCLE_COMMANDS, + COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION, + type ComposedLifecycleCommandContract, +} from "./composedLifecycleContractSet.js"; +import { + TARGET_DEFAULT_CONFIG_STDIN_VERSION, +} from "./targetDefaultConfigStdin.js"; +import { + TARGET_CONFIG_DIGEST_VERSION, + TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION, + TARGET_CONFIG_RESOLUTION_VERSION, +} from "./targetConfigResolver.js"; +import { TARGET_CONFIG_RESOLVER_COMMAND } from "./targetConfigResolverCommand.js"; + +export { COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION } from "./composedLifecycleContractSet.js"; + +export const CAPABILITIES_RECEIPT_VERSION = "spawnfile.capabilities.v1" as const; + +const PACKAGE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; + +export interface CapabilitiesReceipt { + readonly capabilities: { + readonly evidence_export_helper: { + readonly identity: "docker-image-config-digest"; + readonly local_context_only: true; + readonly prepare_command: readonly [ + "helper", "prepare-evidence-export", "--context", "", "--json", + ]; + readonly receipt_version: typeof PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION; + readonly resolver_option: "--prepare-evidence-helper"; + readonly provisioning: "spawnfile-owned-target-local"; + }; + readonly composed_lifecycle: { + readonly command_set_version: typeof COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION; + readonly commands: readonly ComposedLifecycleCommandContract[]; + readonly complete: true; + }; + readonly optional_model_auth: { + readonly request_version: typeof CREDENTIAL_PROVISIONING_REQUEST_VERSION; + readonly required: false; + }; + readonly target_config_resolver: { + readonly command: readonly ["target", typeof TARGET_CONFIG_RESOLVER_COMMAND]; + readonly output_version: typeof TARGET_CONFIG_RESOLUTION_VERSION; + readonly prepared_plan_version: typeof TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION; + readonly target_config_digest_version: typeof TARGET_CONFIG_DIGEST_VERSION; + readonly target_config_version: typeof TARGET_DEFAULT_CONFIG_STDIN_VERSION; + }; + readonly terminal_public_artifact: { + readonly not_present_version: typeof TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION; + readonly request_version: typeof TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION; + readonly snapshot_version: typeof TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION; + }; + }; + readonly implementation: { + readonly cli: "spawnfile"; + readonly package: "spawnfile"; + readonly version: string; + }; + readonly version: typeof CAPABILITIES_RECEIPT_VERSION; +} + +export const createCapabilitiesReceipt = (packageVersion: string): CapabilitiesReceipt => { + if (!PACKAGE_VERSION.test(packageVersion)) throw new TypeError("Invalid Spawnfile package version"); + return Object.freeze({ + capabilities: Object.freeze({ + evidence_export_helper: Object.freeze({ + identity: "docker-image-config-digest" as const, + local_context_only: true as const, + prepare_command: Object.freeze([ + "helper", "prepare-evidence-export", "--context", "", "--json", + ] as const), + receipt_version: PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION, + resolver_option: "--prepare-evidence-helper" as const, + provisioning: "spawnfile-owned-target-local" as const, + }), + composed_lifecycle: Object.freeze({ + command_set_version: COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION, + commands: COMPOSED_LIFECYCLE_COMMANDS, + complete: true as const, + }), + optional_model_auth: Object.freeze({ + request_version: CREDENTIAL_PROVISIONING_REQUEST_VERSION, + required: false as const, + }), + target_config_resolver: Object.freeze({ + command: Object.freeze(["target", TARGET_CONFIG_RESOLVER_COMMAND] as const), + output_version: TARGET_CONFIG_RESOLUTION_VERSION, + prepared_plan_version: TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION, + target_config_digest_version: TARGET_CONFIG_DIGEST_VERSION, + target_config_version: TARGET_DEFAULT_CONFIG_STDIN_VERSION, + }), + terminal_public_artifact: Object.freeze({ + not_present_version: TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION, + request_version: TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION, + snapshot_version: TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION, + }), + }), + implementation: Object.freeze({ + cli: "spawnfile" as const, + package: "spawnfile" as const, + version: packageVersion, + }), + version: CAPABILITIES_RECEIPT_VERSION, + }); +}; + +export const createCapabilitiesReceiptBytes = (receipt: CapabilitiesReceipt): string => + JSON.stringify(receipt); diff --git a/src/cli/composedLifecycleContractSet.ts b/src/cli/composedLifecycleContractSet.ts new file mode 100644 index 00000000..b848d3e3 --- /dev/null +++ b/src/cli/composedLifecycleContractSet.ts @@ -0,0 +1,293 @@ +import { + CREDENTIAL_PROVISIONING_RECEIPT_VERSION, + CREDENTIAL_PROVISIONING_REQUEST_VERSION, + RESOLVED_WORLD_GRANTS_VERSION, +} from "../auth/index.js"; +import { + DOWN_RECEIPT_VERSION, + EXPORT_INDEX_VERSION, + LIFECYCLE_INVOCATION_VERSION, + LIFECYCLE_LOOKUP_VERSION, + UP_RECEIPT_VERSION, +} from "../deployment/index.js"; +import { PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION } from "../evidenceExportHelper/index.js"; +import { + COMPOSED_PREPARATION_RECEIPT_VERSION, + COMPOSED_PREPARATION_REQUEST_VERSION, + SELECTED_TARGET_VERSION, + TARGET_EXPORT_INDEX_VERSION, + TARGET_OPERATION_LOOKUP_VERSION, + TARGET_RESOURCE_RECEIPT_VERSION, + TARGET_RESOURCE_REQUEST_VERSION, + TARGET_TOPOLOGY_ATTESTATION_REQUEST_VERSION, + TARGET_TOPOLOGY_RECEIPT_VERSION, + TARGET_WORLD_CLOCK_RECEIPT_VERSION, + TARGET_WORLD_CLOCK_REQUEST_VERSION, + TARGET_WORLD_READINESS_RECEIPT_VERSION, + TARGET_WORLD_READINESS_REQUEST_VERSION, +} from "../target/index.js"; +import { + TARGET_LOCAL_BUNDLE_LOOKUP_VERSION, + TARGET_LOCAL_BUNDLE_PREPARE_RECEIPT_VERSION, + TARGET_LOCAL_BUNDLE_PREPARE_REQUEST_VERSION, +} from "../target/containerBundleContracts.js"; +import { TARGET_LOCAL_CONTAINER_BUNDLE_POLICY } from "../target/containerBundlePolicy.js"; +import { + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION, + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION, + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION, +} from "../target/publicArtifactSnapshot.js"; +import { TARGET_TOPOLOGY_ACTIVATION_RECEIPT_VERSION } from "../target/topologyActivation.js"; + +import { + LIFECYCLE_PLAN_REQUEST_VERSION, +} from "./lifecyclePlan.js"; +import { + TARGET_DEFAULT_CONFIG_STDIN_VERSION, + TARGET_LOOKUP_CONFIG_STDIN_VERSION, +} from "./targetDefaultConfigStdin.js"; +import { + TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION, + TARGET_CONFIG_RESOLUTION_VERSION, +} from "./targetConfigResolver.js"; +import { + TARGET_SECRET_SOURCE_GRANT_REQUEST_VERSION, + TARGET_SECRET_SOURCE_RECEIPT_VERSION, + TARGET_SECRET_SOURCE_REQUEST_VERSION, +} from "./targetSecretSourceInput.js"; + +export const COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION = + "spawnfile.composed-lifecycle-contract-set.v1" as const; + +export type LifecycleCommandStdout = "json" | "text"; + +/** + * One closed machine-facing command surface. Empty version lists are + * intentional: they mean the command has no versioned value in that role. + */ +export interface ComposedLifecycleCommandContract { + readonly argv: readonly string[]; + readonly invocation_versions: readonly string[]; + readonly pending_versions: readonly string[]; + readonly receipt_versions: readonly string[]; + readonly request_versions: readonly string[]; + readonly stdin_versions: readonly string[]; + readonly stdout: LifecycleCommandStdout; +} + +interface CommandVersions { + readonly invocation_versions?: readonly string[]; + readonly pending_versions?: readonly string[]; + readonly receipt_versions?: readonly string[]; + readonly request_versions?: readonly string[]; + readonly stdin_versions?: readonly string[]; + readonly stdout: LifecycleCommandStdout; +} + +const immutable = (values: readonly string[] = []): readonly string[] => + Object.freeze([...values]); + +const command = ( + argv: readonly string[], + versions: CommandVersions, +): ComposedLifecycleCommandContract => Object.freeze({ + argv: immutable(argv), + invocation_versions: immutable(versions.invocation_versions), + pending_versions: immutable(versions.pending_versions), + receipt_versions: immutable(versions.receipt_versions), + request_versions: immutable(versions.request_versions), + stdin_versions: immutable(versions.stdin_versions), + stdout: versions.stdout, +}); + +const targetMutation = ( + operation: string, + receiptVersions: readonly string[] = [TARGET_RESOURCE_RECEIPT_VERSION], +): ComposedLifecycleCommandContract => command( + ["target", "--config", "-", operation, ""], + { + receipt_versions: receiptVersions, + request_versions: [TARGET_RESOURCE_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }, +); + +const machineLifecycle = (argv: readonly string[], receiptVersion: string): ComposedLifecycleCommandContract => + command(argv, { + invocation_versions: [LIFECYCLE_INVOCATION_VERSION], + pending_versions: [LIFECYCLE_LOOKUP_VERSION], + receipt_versions: [receiptVersion], + stdout: "json", + }); + +/** + * Closed inventory for consumers that compose only public CLI and receipt + * contracts. `argv` records one canonical invocation form, not every + * presentation-only optional flag accepted by Commander. + */ +export const COMPOSED_LIFECYCLE_COMMANDS: readonly ComposedLifecycleCommandContract[] = + Object.freeze([ + command(["capabilities", "--json"], { + receipt_versions: ["spawnfile.capabilities.v1"], stdout: "json", + }), + command(["validate", ""], { stdout: "text" }), + command(["compile", "", "--out", ""], { stdout: "text" }), + command(["auth", "provision", ""], { + receipt_versions: [CREDENTIAL_PROVISIONING_RECEIPT_VERSION], + request_versions: [CREDENTIAL_PROVISIONING_REQUEST_VERSION, RESOLVED_WORLD_GRANTS_VERSION], + stdout: "json", + }), + command(["auth", "target-secret", "author"], { + receipt_versions: [TARGET_SECRET_SOURCE_RECEIPT_VERSION], stdout: "json", + }), + command(["auth", "target-secret", "grant", ""], { + receipt_versions: [TARGET_SECRET_SOURCE_RECEIPT_VERSION], + request_versions: [TARGET_SECRET_SOURCE_GRANT_REQUEST_VERSION], + stdout: "json", + }), + command(["auth", "target-secret", "rotate", ""], { + receipt_versions: [TARGET_SECRET_SOURCE_RECEIPT_VERSION], + request_versions: [TARGET_SECRET_SOURCE_REQUEST_VERSION], + stdout: "json", + }), + command(["auth", "target-secret", "revoke-grant", ""], { + receipt_versions: [TARGET_SECRET_SOURCE_RECEIPT_VERSION], + request_versions: [TARGET_SECRET_SOURCE_REQUEST_VERSION], + stdout: "json", + }), + command(["auth", "target-secret", "revoke-version", ""], { + receipt_versions: [TARGET_SECRET_SOURCE_RECEIPT_VERSION], + request_versions: [TARGET_SECRET_SOURCE_REQUEST_VERSION], + stdout: "json", + }), + command(["helper", "prepare-evidence-export", "--context", "", "--json"], { + receipt_versions: [PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION], stdout: "json", + }), + command(["target", "resolve_config", "--evidence-destination", ""], { + receipt_versions: [TARGET_CONFIG_RESOLUTION_VERSION], stdout: "json", + }), + command([ + "target", "resolve_config", "--evidence-destination", "", + "--prepared-plan", "", + ], { + receipt_versions: [TARGET_CONFIG_RESOLUTION_VERSION], + request_versions: [TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION], + stdout: "json", + }), + command([ + "target", "resolve_config", "--context", "", + "--evidence-destination", "", "--prepare-evidence-helper", + ], { + receipt_versions: [TARGET_CONFIG_RESOLUTION_VERSION, PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "select_target", ""], { + receipt_versions: [SELECTED_TARGET_VERSION], + request_versions: [TARGET_RESOURCE_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + ...[ + "resolve_world_artifact", "prepare_secret_bindings", "create_data_network", + "create_evidence_volume", "attach_organization", "create_world_service", + "start_world_service", "stop_world_service", + "revoke_secret_bindings", "detach_organization", "cleanup_run", "recover_operation", + ].map((operation) => targetMutation(operation)), + command(["target", "--config", "-", "prepare_composed_run", ""], { + receipt_versions: [COMPOSED_PREPARATION_RECEIPT_VERSION], + request_versions: [COMPOSED_PREPARATION_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "attest_topology", ""], { + receipt_versions: [TARGET_TOPOLOGY_RECEIPT_VERSION], + request_versions: [TARGET_TOPOLOGY_ATTESTATION_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "activate_topology", ""], { + receipt_versions: [TARGET_TOPOLOGY_ACTIVATION_RECEIPT_VERSION], + request_versions: [TARGET_TOPOLOGY_ATTESTATION_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "query_world_readiness", ""], { + receipt_versions: [TARGET_WORLD_READINESS_RECEIPT_VERSION], + request_versions: [TARGET_WORLD_READINESS_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "query_world_clock", ""], { + receipt_versions: [TARGET_WORLD_CLOCK_RECEIPT_VERSION], + request_versions: [TARGET_WORLD_CLOCK_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "snapshot_public_artifact", ""], { + receipt_versions: [ + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION, + TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION, + ], + request_versions: [TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "lookup_operation", ""], { + pending_versions: [TARGET_OPERATION_LOOKUP_VERSION], + receipt_versions: [TARGET_OPERATION_LOOKUP_VERSION], + request_versions: [TARGET_RESOURCE_REQUEST_VERSION], + stdin_versions: [TARGET_LOOKUP_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "derive_container_bundle_policy", ""], { + receipt_versions: [TARGET_LOCAL_CONTAINER_BUNDLE_POLICY.version], stdout: "json", + }), + command(["target", "--config", "-", "prepare_container_bundle", ""], { + receipt_versions: [TARGET_LOCAL_BUNDLE_PREPARE_RECEIPT_VERSION], + request_versions: [TARGET_LOCAL_BUNDLE_PREPARE_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "recover_container_bundle", ""], { + receipt_versions: [TARGET_LOCAL_BUNDLE_PREPARE_RECEIPT_VERSION], + request_versions: [TARGET_LOCAL_BUNDLE_PREPARE_REQUEST_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["target", "--config", "-", "lookup_container_bundle", ""], { + pending_versions: [TARGET_LOCAL_BUNDLE_LOOKUP_VERSION], + receipt_versions: [TARGET_LOCAL_BUNDLE_LOOKUP_VERSION], + request_versions: [TARGET_LOCAL_BUNDLE_LOOKUP_VERSION], + stdin_versions: [TARGET_DEFAULT_CONFIG_STDIN_VERSION], + stdout: "json", + }), + command(["lifecycle", "plan", "--request", ""], { + receipt_versions: [LIFECYCLE_INVOCATION_VERSION], + request_versions: [LIFECYCLE_PLAN_REQUEST_VERSION], + stdout: "json", + }), + command(["lifecycle", "lookup", ""], { + pending_versions: [LIFECYCLE_LOOKUP_VERSION], + receipt_versions: [LIFECYCLE_LOOKUP_VERSION], + stdout: "json", + }), + machineLifecycle([ + "up", "", "--detach", "--deployment", "", "--json", + "--lifecycle-invocation", "", "--organization-handoff-run-id", "", + "--descriptor-digest", "", "--selected-target-receipt", "", + "--selected-target-receipt-digest", "", "--network-attachment-handle", "", + "--world-bindings", "", + ], UP_RECEIPT_VERSION), + machineLifecycle([ + "artifacts", "export", "", "--out", "", "--json", + "--lifecycle-invocation", "", + ], EXPORT_INDEX_VERSION), + machineLifecycle([ + "down", "", "--deployment", "", "--json", + "--lifecycle-invocation", "", + ], DOWN_RECEIPT_VERSION), + targetMutation("export_evidence_volume", [ + TARGET_RESOURCE_RECEIPT_VERSION, TARGET_EXPORT_INDEX_VERSION, + ]), + ]); diff --git a/src/cli/evidenceExportHelperCommand.test.ts b/src/cli/evidenceExportHelperCommand.test.ts new file mode 100644 index 00000000..ddf222f0 --- /dev/null +++ b/src/cli/evidenceExportHelperCommand.test.ts @@ -0,0 +1,47 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +import { parsePreparedEvidenceHelperReceipt } from "../evidenceExportHelper/index.js"; +import { + createDefaultEvidenceExportHelperPreparer, + registerEvidenceExportHelperCommand, +} from "./evidenceExportHelperCommand.js"; + +const receipt = parsePreparedEvidenceHelperReceipt({ digest: `sha256:${"a".repeat(64)}`, + handle: `opaque_${"b".repeat(64)}`, version: "spawnfile.target-evidence-export-helper.prepared.v1" }); +const invoke = async (argv: string[], preparer = vi.fn(async () => receipt)) => { + const stdout: string[] = []; const stderr: string[] = []; let exitCode = 0; + const program = new Command().exitOverride(); + registerEvidenceExportHelperCommand(program, { stderr: (message) => stderr.push(message), + stdout: (message) => stdout.push(message) }, (value) => { exitCode = value; }, preparer); + await program.parseAsync(argv, { from: "user" }); + return { exitCode, preparer, stderr, stdout }; +}; + +describe("evidence export helper command", () => { + it("routes the default preparer through the helper-specific executor", async () => { + const executor = vi.fn(async () => { throw new Error("bounded"); }); + const executorFor = vi.fn(() => executor); + const preparer = createDefaultEvidenceExportHelperPreparer(executorFor); + await expect(preparer({ baseImage: "node:22-bookworm-slim", context: "local_dev", + dockerCommand: "docker-safe", timeoutMs: 123 })).rejects.toThrow("bounded"); + expect(executorFor).toHaveBeenCalledWith("docker-safe"); + expect(executor).toHaveBeenCalledWith("docker", [ + "--context", "local_dev", "context", "inspect", "local_dev", "--format", + "{{json .Endpoints.docker.Host}}", + ], { timeout: 123 }); + }); + it("emits only the versioned opaque receipt", async () => { + const result = await invoke(["helper", "prepare-evidence-export", "--context", "local_dev", "--json"]); + expect(result.exitCode).toBe(0); expect(result.stderr).toEqual([]); + expect(JSON.parse(result.stdout[0]!)).toEqual(receipt); + expect(result.preparer).toHaveBeenCalledWith({ baseImage: "node:22-bookworm-slim", context: "local_dev", + dockerCommand: "docker", timeoutMs: 120_000 }); + }); + it("redacts option and preparation failures", async () => { + const invalid = await invoke(["helper", "prepare-evidence-export", "--context", "local_dev", "--timeout-ms", "120001", "--json"]); + expect(invalid.exitCode).toBe(2); expect(invalid.preparer).not.toHaveBeenCalled(); + const failed = await invoke(["helper", "prepare-evidence-export", "--context", "local_dev", "--json"], vi.fn(async () => { throw new Error("private Docker detail"); })); + expect(failed.exitCode).toBe(1); expect(failed.stderr).toEqual(["error: Prepared evidence-export helper failed"]); + }); +}); diff --git a/src/cli/evidenceExportHelperCommand.ts b/src/cli/evidenceExportHelperCommand.ts new file mode 100644 index 00000000..4293a3a5 --- /dev/null +++ b/src/cli/evidenceExportHelperCommand.ts @@ -0,0 +1,72 @@ +import type { Command } from "commander"; + +import { + createPreparedEvidenceHelperExecutor, + prepareEvidenceExportHelper, + type PreparedEvidenceHelperReceipt, +} from "../evidenceExportHelper/index.js"; +import type { DockerArtifactExecutor } from "../target/dockerArtifactsProvider.js"; +import { resolveSpawnfileHome } from "../auth/index.js"; +import path from "node:path"; + +import { STANDARD_WORLD_BASE_IMAGE } from "./targetConfigResolver.js"; +import type { CliStreams } from "./runCli.js"; + +interface CommandOptions { + readonly baseImage: string; + readonly context: string; + readonly dockerCommand: string; + readonly json: boolean; + readonly timeoutMs: string; +} +export interface PrepareEvidenceExportHelperInput { + readonly baseImage: string; + readonly context: string; + readonly dockerCommand: string; + readonly timeoutMs: number; +} +export type PrepareEvidenceExportHelper = (input: PrepareEvidenceExportHelperInput) => Promise; + +const timeout = (raw: string): number => { + if (!/^[1-9][0-9]{0,5}$/u.test(raw)) throw new TypeError(); + const value = Number(raw); + if (value > 120_000) throw new TypeError(); + return value; +}; +export const createDefaultEvidenceExportHelperPreparer = ( + executorFor: (dockerCommand: string) => DockerArtifactExecutor = createPreparedEvidenceHelperExecutor, +): PrepareEvidenceExportHelper => async (input) => prepareEvidenceExportHelper({ + baseImage: input.baseImage, + context: input.context, + executor: executorFor(input.dockerCommand), + privateRoot: path.join(resolveSpawnfileHome(), "target", "evidence-helper"), + timeoutMs: input.timeoutMs, +}); +const defaultPreparer = createDefaultEvidenceExportHelperPreparer(); + +export const registerEvidenceExportHelperCommand = ( + program: Command, + streams: CliStreams, + setExitCode: (value: 1 | 2) => void, + preparer: PrepareEvidenceExportHelper = defaultPreparer, +): void => { + const helper = program.command("helper") + .description("Prepare package-owned local development helpers"); + helper.command("prepare-evidence-export") + .description("Prepare Spawnfile-owned local evidence export helper") + .requiredOption("--context ", "Explicit local Docker context") + .option("--base-image ", "Already-present Node base image", STANDARD_WORLD_BASE_IMAGE) + .option("--docker-command ", "Docker-compatible command", "docker") + .option("--timeout-ms ", "Bounded command timeout", "120000") + .requiredOption("--json", "Emit the versioned opaque receipt JSON") + .action(async (options: CommandOptions) => { + let timeoutMs: number; + try { timeoutMs = timeout(options.timeoutMs); } + catch { streams.stderr("error: Invalid local evidence-export helper options"); setExitCode(2); return; } + try { + const receipt = await preparer({ baseImage: options.baseImage, context: options.context, + dockerCommand: options.dockerCommand, timeoutMs }); + streams.stdout(JSON.stringify(receipt)); + } catch { streams.stderr("error: Prepared evidence-export helper failed"); setExitCode(1); } + }); +}; diff --git a/src/cli/lifecycleMachine.ts b/src/cli/lifecycleMachine.ts index b51ad28f..265d3998 100644 --- a/src/cli/lifecycleMachine.ts +++ b/src/cli/lifecycleMachine.ts @@ -9,6 +9,7 @@ import { renewLifecycleOwner, type LifecycleInvocation, } from "../deployment/index.js"; +import type { UpLifecycleRecovery } from "../deployment/upLifecycleRecoveryState.js"; import { SpawnfileError } from "../shared/index.js"; export const createLifecycleInvocation = ( @@ -33,8 +34,8 @@ export const digestLifecycleBinding = ( export type LifecycleReconciliation = | { outcomeBytes: string; status: "completed" } - | { status: "provably_not_applied" } - | { status: "resume_safe" } + | { recovery?: UpLifecycleRecovery; status: "provably_not_applied" } + | { recovery?: UpLifecycleRecovery; status: "resume_safe" } | { reason: string; status: "ambiguous" }; const withLifecycleHeartbeat = async ( @@ -64,8 +65,8 @@ export const runMachineLifecycle = async ( owner: (capability: { epoch: string; role: "initial" | "recovery"; - }) => Promise, - reconcile?: () => Promise, + }, recovery?: UpLifecycleRecovery) => Promise, + reconcile?: (capability: { epoch: string; role: "initial" | "recovery" }) => Promise, ): Promise => { const existing = await findExactLifecycleCompletion(invocation); if (existing) return existing.outcome_bytes; @@ -94,7 +95,7 @@ export const runMachineLifecycle = async ( const verdict = await withLifecycleHeartbeat( invocation, recovery.capability, - reconcile, + () => reconcile(recovery.capability), ); if (verdict.status === "completed") { return ( @@ -115,7 +116,7 @@ export const runMachineLifecycle = async ( await withLifecycleHeartbeat( invocation, recovery.capability, - () => owner(recovery.capability), + () => owner(recovery.capability, verdict.recovery), ), recovery.capability, ) diff --git a/src/cli/runCli.test.ts b/src/cli/runCli.test.ts index f5fa3579..921c2d05 100644 --- a/src/cli/runCli.test.ts +++ b/src/cli/runCli.test.ts @@ -37,6 +37,32 @@ afterEach(async () => { }); describe("runCli", () => { + it("emits the closed generic contract receipt without reading stdin", async () => { + const stdout: string[] = []; + let stdinReads = 0; + const exitCode = await runCli(["capabilities", "--json"], { + stdin: (async function* () { + stdinReads += 1; + yield "unread"; + })(), + streams: { stderr: () => undefined, stdout: (value) => stdout.push(value) }, + }); + + expect(exitCode).toBe(0); + expect(stdinReads).toBe(0); + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0]!)).toMatchObject({ + capabilities: { + composed_lifecycle: { + command_set_version: "spawnfile.composed-lifecycle-contract-set.v1", + complete: true, + }, + }, + implementation: { package: "spawnfile", version: packageVersion }, + version: "spawnfile.capabilities.v1", + }); + }); + it("requires literal target config stdin and bounds config failures before effects", async () => { const directory = await mkdtemp( path.join(os.tmpdir(), "spawnfile-target-cli-"), @@ -1983,16 +2009,25 @@ describe("runCli", () => { } }); - it("rejects `up --json` for image-mode deployments", async () => { + it("rejects image-mode JSON because only project mode has a durable lifecycle contract", async () => { + const stdout: string[] = []; const stderr: string[] = []; + const consumeImageUp = vi.fn(async () => ({ + containerName: "spawnfile-prod", + deploymentName: "prod", + imageRef: "you/org:1.0.0", + record: {} as never, + recordPath: "/private/deployments/prod/record.json", + })); const exitCode = await runCli( ["up", "you/org:1.0.0", "--deployment", "prod", "--detach", "--json"], - { stderr: (message) => stderr.push(message), stdout: () => undefined }, + { stderr: (message) => stderr.push(message), stdout: (message) => stdout.push(message) }, + { consumeImageUp: consumeImageUp as never }, ); expect(exitCode).toBe(2); - expect(stderr.join("\n")).toContain( - "not yet supported for image-mode deployments", - ); + expect(stdout).toEqual([]); + expect(stderr.join("\n")).toContain("supported only for project deployments"); + expect(consumeImageUp).not.toHaveBeenCalled(); }); it("tears down a deployment and renders a spawnfile.down-receipt.v1 with `down --json`", async () => { diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index aa8d27b5..b642e103 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -47,7 +47,9 @@ import { errorExitCode, isSpawnfileError } from "../shared/index.js"; import { listRuntimeAdapters } from "../runtime/index.js"; import { registerArtifactsCommands } from "./artifactsCommands.js"; import { registerAuthCommands } from "./authCommands.js"; +import { registerCapabilitiesCommand } from "./capabilitiesCommand.js"; import { registerDevCommands } from "./devCommands.js"; +import { registerEvidenceExportHelperCommand } from "./evidenceExportHelperCommand.js"; import { registerLifecycleCommands } from "./lifecycleCommands.js"; import { registerModelCommands } from "./modelCommands.js"; import { registerRuntimeCommands } from "./runtimeCommands.js"; @@ -229,6 +231,10 @@ export const runCli: RunCli = async ( registerLifecycleCommands(program, handlers, streams, cliOptions.stdin); registerDevCommands(program, handlers, streams); registerArtifactsCommands(program, handlers, streams); + registerCapabilitiesCommand(program, streams, readPackageVersion()); + registerEvidenceExportHelperCommand(program, streams, (exitCode) => { + commandExitCode = exitCode; + }); program .command("init") diff --git a/src/cli/targetCommands.test.ts b/src/cli/targetCommands.test.ts index 01f070cb..9a726868 100644 --- a/src/cli/targetCommands.test.ts +++ b/src/cli/targetCommands.test.ts @@ -11,7 +11,9 @@ import { createCanonicalTargetReceiptBytes, createCanonicalTargetTopologyReceiptBytes, createCanonicalTargetPublicArtifactSnapshotBytes, + createCanonicalTargetPublicArtifactSnapshotResultBytes, createTargetPublicArtifactSnapshot, + createTargetPublicArtifactSnapshotNotPresent, createCanonicalTargetTopologyActivationReceiptBytes, createTargetReceiptDigest, createTargetRequestDigest, @@ -507,6 +509,19 @@ describe("target command registration", () => { .not.toContain("snapshot_public_artifact"); }); + it("emits typed public-artifact absence as a canonical successful query", async () => { + const notPresent = createTargetPublicArtifactSnapshotNotPresent(publicArtifactRequest); + const session: TargetCommandHandlerSession = { + run: sessionFor(handlers([])).run, + snapshotPublicArtifact: async () => notPresent + }; + await expect(runPublicArtifactSnapshot(session)).resolves.toEqual({ + exits: [], + stderr: [], + stdout: [createCanonicalTargetPublicArtifactSnapshotResultBytes(notPresent)] + }); + }); + it("routes topology activation separately and emits one canonical receipt", async () => { const calls: string[] = []; const session = Object.freeze({ diff --git a/src/cli/targetCommands.ts b/src/cli/targetCommands.ts index 5f862fd7..3b9a3b4e 100644 --- a/src/cli/targetCommands.ts +++ b/src/cli/targetCommands.ts @@ -5,9 +5,9 @@ import { createTargetTopologyReceiptDigest } from "../target/handles.js"; import { - createCanonicalTargetPublicArtifactSnapshotBytes, - type TargetPublicArtifactSnapshot, - type TargetPublicArtifactSnapshotRequest + createCanonicalTargetPublicArtifactSnapshotResultBytes, + type TargetPublicArtifactSnapshotRequest, + type TargetPublicArtifactSnapshotResult } from "../target/publicArtifactSnapshot.js"; import { createCanonicalTargetTopologyActivationReceiptBytes, @@ -66,7 +66,7 @@ export interface TargetCommandHandlerSession extends TargetWorldReadinessSession attestTopology?(request: TargetTopologyAttestationRequest): Promise; snapshotPublicArtifact?( request: TargetPublicArtifactSnapshotRequest - ): Promise; + ): Promise; } export interface TargetCommandStreams { @@ -271,7 +271,7 @@ const registerPublicArtifactSnapshot = ( return; } try { - streams.stdout(createCanonicalTargetPublicArtifactSnapshotBytes( + streams.stdout(createCanonicalTargetPublicArtifactSnapshotResultBytes( await activeSession.snapshotPublicArtifact(request) )); } catch (error) { diff --git a/src/cli/targetConfigPreparedPlan.ts b/src/cli/targetConfigPreparedPlan.ts new file mode 100644 index 00000000..21d71e9f --- /dev/null +++ b/src/cli/targetConfigPreparedPlan.ts @@ -0,0 +1,74 @@ +import { constants } from "node:fs"; +import { lstat, open } from "node:fs/promises"; +import path from "node:path"; + +import { SpawnfileError } from "../shared/index.js"; + +import { + parseTargetPreparedArtifactMappings, + type PreparedArtifactMapping, +} from "./targetDefaultConfig.js"; + +export const TARGET_CONFIG_PREPARED_PLAN_VERSION = + "spawnfile.target-config-prepared-plan.v1" as const; + +const MAX_PATH_BYTES = 4_096; +const MAX_PREPARED_PLAN_BYTES = 128 * 1_024; +const fail = (message: string): never => { + throw new SpawnfileError("validation_error", message); +}; +const planPath = (value: unknown): string => { + if (typeof value !== "string" || value.includes("\0") + || Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES + || !path.isAbsolute(value) || path.normalize(value) !== value) { + return fail("Prepared target plan path must be absolute and normalized"); + } + return value; +}; + +export const readTargetConfigPreparedPlan = async ( + rawPath: string | undefined, + evidenceDestination: string, +): Promise => { + if (rawPath === undefined) return undefined; + const resolved = planPath(rawPath); + const owner = process.getuid?.(); + let handle; + try { + const before = await lstat(resolved); + if (!before.isFile() || before.isSymbolicLink() || before.size < 1 + || before.size > MAX_PREPARED_PLAN_BYTES || (before.mode & 0o777) !== 0o600 + || owner !== undefined && before.uid !== owner) { + return fail("Prepared target plan must be a private bounded regular file"); + } + handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW); + const bytes = await handle.readFile(); + const after = await handle.stat(); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== bytes.byteLength) { + return fail("Prepared target plan changed while it was read"); + } + let source: string; + try { source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } + catch { return fail("Prepared target plan must be UTF-8 JSON"); } + const parsed = JSON.parse(source) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) + || Object.keys(parsed).sort().join("\0") + !== "evidence_destination\0prepared_artifact_mapping\0version") { + return fail("Prepared target plan has an invalid shape"); + } + const value = parsed as Record; + if (value.version !== TARGET_CONFIG_PREPARED_PLAN_VERSION) { + return fail("Prepared target plan version is invalid"); + } + if (value.evidence_destination !== evidenceDestination) { + return fail("Prepared target plan evidence destination does not match"); + } + try { return parseTargetPreparedArtifactMappings([value.prepared_artifact_mapping]); } + catch { return fail("Prepared target plan artifact mapping is invalid"); } + } catch (error) { + if (error instanceof SpawnfileError) throw error; + return fail("Prepared target plan is unavailable"); + } finally { + await handle?.close().catch(() => undefined); + } +}; diff --git a/src/cli/targetConfigResolver.test.ts b/src/cli/targetConfigResolver.test.ts index 56135855..e0b489ac 100644 --- a/src/cli/targetConfigResolver.test.ts +++ b/src/cli/targetConfigResolver.test.ts @@ -2,11 +2,17 @@ import os from "node:os"; import path from "node:path"; import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parsePreparedEvidenceHelperReceipt, + type PrepareEvidenceHelperInput, +} from "../evidenceExportHelper/index.js"; import type { DockerTargetExecFile } from "../target/dockerTarget.js"; import { + createCanonicalTargetConfigBytes, + createTargetConfigDigest, createTargetConfigResolutionBytes, resolveTargetConfig, STANDARD_WORLD_BASE_IMAGE, @@ -31,23 +37,28 @@ interface FakeDockerOptions { readonly imageId?: string; readonly imageInspectFails?: boolean; readonly imageOs?: string; + readonly helperConfigId?: string; + readonly helperImageDigest?: string; readonly os?: string; } const fakeDocker = (options: FakeDockerOptions = {}) => { const calls: string[][] = []; - const execFile: DockerTargetExecFile = async (_command, args) => { + const commands: string[] = []; + const execFile: DockerTargetExecFile = async (command, args) => { + commands.push(command); calls.push([...args]); - if (args[0] === "context" && args[1] === "show") { + const dockerArgs = args[0] === "--context" ? args.slice(2) : args; + if (dockerArgs[0] === "context" && dockerArgs[1] === "show") { return { stderr: "", stdout: "local-dev\n" }; } - if (args[0] === "context" && args[1] === "inspect") { + if (dockerArgs[0] === "context" && dockerArgs[1] === "inspect") { return { stderr: "", stdout: `${JSON.stringify(options.endpoint ?? "unix:///tmp/docker.sock")}\n`, }; } - if (args[2] === "info") { + if (dockerArgs[0] === "info") { return { stderr: "", stdout: JSON.stringify({ @@ -56,23 +67,41 @@ const fakeDocker = (options: FakeDockerOptions = {}) => { }), }; } - if (args[2] === "image" && args[3] === "pull") { + if (dockerArgs[0] === "image" && dockerArgs[1] === "pull") { return { stderr: "", stdout: `${digest("f")}\n` }; } - if (args[2] === "image" && args[3] === "inspect") { + if (dockerArgs[0] === "image" && dockerArgs[1] === "inspect") { + if (dockerArgs[2]?.startsWith("spawnfile-local/evidence-export-helper@")) { + return { + stderr: "", + stdout: JSON.stringify([{ + Architecture: "arm64", + Config: { + Cmd: [], Entrypoint: ["/bin/spawnfile-export-helper"], + Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + ExposedPorts: null, Healthcheck: null, + Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, + User: "65534:65534", Volumes: null, + }, + Id: options.helperConfigId ?? digest("b"), Os: "linux", + RepoDigests: [ + `spawnfile-local/evidence-export-helper@${options.helperImageDigest ?? digest("c")}`, + ], + }]), + }; + } if (options.imageInspectFails === true) throw new Error("missing"); - return { - stderr: "", - stdout: JSON.stringify({ - Architecture: options.imageArchitecture ?? options.architecture ?? "arm64", - Id: options.imageId ?? digest("a"), - Os: options.imageOs ?? "linux", - }), + const projection = { + Architecture: options.imageArchitecture ?? options.architecture ?? "arm64", + Id: options.imageId ?? digest("a"), + Os: options.imageOs ?? "linux", }; + return { stderr: "", stdout: JSON.stringify(dockerArgs[4]?.startsWith("[") + ? [projection] : projection) }; } throw new Error(`unexpected Docker args: ${args.join(" ")}`); }; - return { calls, execFile }; + return { calls, commands, execFile }; }; const preparedMapping = Object.freeze({ @@ -102,20 +131,24 @@ describe("target config resolver", () => { execFile: docker.execFile, }); + const targetConfig = Object.freeze({ + context: "local-dev", + dockerCommand: "docker", + evidenceDestination, + timeoutMs: 10_000, + version: "spawnfile.target-default-config.v1" as const, + }); expect(resolution).toEqual({ base_image: { config_digest: digest("a"), reference: STANDARD_WORLD_BASE_IMAGE }, context_selection: "explicit", endpoint: { class: "local", transport: "unix" }, platform: { architecture: "arm64", os: "linux" }, - target_config: { - context: "local-dev", - dockerCommand: "docker", - evidenceDestination, - timeoutMs: 10_000, - version: "spawnfile.target-default-config.v1", - }, + target_config: targetConfig, + target_config_digest: createTargetConfigDigest(targetConfig), version: "spawnfile.target-config-resolution.v1", }); + expect(createCanonicalTargetConfigBytes(resolution.target_config)) + .toBe(JSON.stringify(targetConfig)); expect(JSON.parse(createTargetConfigResolutionBytes(resolution))).toEqual(resolution); expect(docker.calls).toHaveLength(3); expect(docker.calls.some((args) => args.includes("pull"))).toBe(false); @@ -193,6 +226,7 @@ describe("target config resolver", () => { await writeFile(planPath, JSON.stringify({ evidence_destination: evidenceDestination, prepared_artifact_mapping: preparedMapping, + version: "spawnfile.target-config-prepared-plan.v1", }), { mode: 0o600 }); const docker = fakeDocker(); const resolution = await resolveTargetConfig({ @@ -202,6 +236,7 @@ describe("target config resolver", () => { preparedPlanPath: planPath, }); expect(resolution.target_config.preparedArtifactMappings).toEqual([preparedMapping]); + expect(resolution.target_config_digest).toBe(createTargetConfigDigest(resolution.target_config)); await chmod(planPath, 0o644); await expect(resolveTargetConfig({ @@ -212,6 +247,97 @@ describe("target config resolver", () => { })).rejects.toThrow(/private bounded regular file/u); }); + it("returns a correlated opaque helper receipt only for explicit local preparation", async () => { + const evidenceDestination = await privateEvidenceDestination(); + const docker = fakeDocker(); + const receipt = parsePreparedEvidenceHelperReceipt({ + digest: digest("d"), + handle: `opaque_${"e".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", + }); + const prepareEvidenceExportHelper = vi.fn(async () => receipt); + const resolution = await resolveTargetConfig({ + context: "local-dev", + evidenceDestination, + execFile: docker.execFile, + prepareEvidenceHelper: true, + }, { prepareEvidenceExportHelper }); + + expect(prepareEvidenceExportHelper).toHaveBeenCalledWith(expect.objectContaining({ + baseImage: STANDARD_WORLD_BASE_IMAGE, + context: "local-dev", + timeoutMs: 10_000, + })); + expect(resolution.prepared_evidence_helper).toEqual(receipt); + expect(resolution.target_config).toMatchObject({ + evidenceHelperBaseImage: STANDARD_WORLD_BASE_IMAGE, + preparedEvidenceHelper: receipt, + }); + expect(resolution.target_config_digest).toBe(createTargetConfigDigest(resolution.target_config)); + expect(Object.keys(resolution.prepared_evidence_helper ?? {})).toEqual([ + "digest", "handle", "version", + ]); + }); + + it("uses the configured Docker-compatible executable for every helper call", async () => { + const evidenceDestination = await privateEvidenceDestination(); + const docker = fakeDocker(); + const receipt = parsePreparedEvidenceHelperReceipt({ + digest: digest("d"), + handle: `opaque_${"e".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", + }); + const prepareEvidenceExportHelper = vi.fn(async (input: PrepareEvidenceHelperInput) => { + await input.executor("docker", [ + "--context", "local-dev", "context", "inspect", "local-dev", "--format", + "{{json .Endpoints.docker.Host}}", + ], { timeout: 10_000 } as never); + return receipt; + }); + await expect(resolveTargetConfig({ + context: "local-dev", + dockerCommand: "docker-compatible", + evidenceDestination, + execFile: docker.execFile, + prepareEvidenceHelper: true, + }, { prepareEvidenceExportHelper })).resolves.toMatchObject({ + prepared_evidence_helper: receipt, + }); + expect(docker.commands).toEqual(docker.calls.map(() => "docker-compatible")); + expect(docker.commands).not.toContain("docker"); + }); + + it("refuses helper preparation without an explicit local target", async () => { + const evidenceDestination = await privateEvidenceDestination(); + const docker = fakeDocker(); + const prepareEvidenceExportHelper = vi.fn(); + await expect(resolveTargetConfig({ + evidenceDestination, + execFile: docker.execFile, + prepareEvidenceHelper: true, + }, { prepareEvidenceExportHelper })).rejects.toThrow(/explicitly selected local/u); + expect(prepareEvidenceExportHelper).not.toHaveBeenCalled(); + expect(docker.calls).toHaveLength(2); + }); + + it("does not pull or prepare a helper on an explicitly remote target", async () => { + const evidenceDestination = await privateEvidenceDestination(); + const docker = fakeDocker({ endpoint: "ssh://operator@example.test" }); + const prepareEvidenceExportHelper = vi.fn(); + await expect(resolveTargetConfig({ + allowRemotePull: true, + context: "remote-prod", + evidenceDestination, + execFile: docker.execFile, + prepareEvidenceHelper: true, + pull: true, + }, { prepareEvidenceExportHelper })).rejects.toThrow(/explicitly selected local/u); + expect(prepareEvidenceExportHelper).not.toHaveBeenCalled(); + expect(docker.calls).toEqual([[ + "context", "inspect", "remote-prod", "--format", "{{json .Endpoints.docker.Host}}", + ]]); + }); + it("rejects invalid inputs and platform or image identity drift", async () => { const evidenceDestination = await privateEvidenceDestination(); const unused = fakeDocker(); diff --git a/src/cli/targetConfigResolver.ts b/src/cli/targetConfigResolver.ts index 1deda428..21954eff 100644 --- a/src/cli/targetConfigResolver.ts +++ b/src/cli/targetConfigResolver.ts @@ -1,305 +1,60 @@ import path from "node:path"; -import { constants } from "node:fs"; -import { lstat, open, realpath } from "node:fs/promises"; -import { parseImageReference } from "../distribution/index.js"; -import { SpawnfileError } from "../shared/index.js"; +import { resolveSpawnfileHome } from "../auth/index.js"; import { - defaultDockerTargetExecFile, - resolveDockerContextEndpoint, -} from "../target/dockerTargetBinding.js"; -import type { DockerTargetExecFile } from "../target/dockerTarget.js"; + createPreparedEvidenceHelperExecutor, + parsePreparedEvidenceHelperReceipt, + prepareEvidenceExportHelper, + type PreparedEvidenceHelperReceipt, +} from "../evidenceExportHelper/index.js"; +import { defaultDockerTargetExecFile, resolveDockerContextEndpoint } from "../target/dockerTargetBinding.js"; import { - parseTargetPreparedArtifactMappings, - type PreparedArtifactMapping, -} from "./targetDefaultConfig.js"; + createTargetConfigDigest, + STANDARD_WORLD_BASE_IMAGE, + TARGET_CONFIG_RESOLUTION_VERSION, + type ResolveTargetConfigDependencies, + type ResolveTargetConfigInput, + type TargetConfigResolution, +} from "./targetConfigResolverContracts.js"; +import { exactJson, executeDocker, resolveCurrentDockerContext } from "./targetConfigResolverDocker.js"; +import { + classifyEndpoint, + normalizeArchitecture, + parseBaseImage, + parseContext, + parseDockerCommand, + parseTimeout, + runtimeFailure, + validateEvidenceDestination, + validationFailure, +} from "./targetConfigResolverValidation.js"; +import { readTargetConfigPreparedPlan } from "./targetConfigPreparedPlan.js"; import { TARGET_DEFAULT_CONFIG_STDIN_VERSION } from "./targetDefaultConfigStdin.js"; -export const TARGET_CONFIG_RESOLUTION_VERSION = - "spawnfile.target-config-resolution.v1" as const; -export const STANDARD_WORLD_BASE_IMAGE = "node:22-bookworm-slim"; - -const CONTEXT = /^[a-z][a-z0-9_-]{0,63}$/u; -const COMMAND_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; -const CONFIG_DIGEST = /^sha256:[a-f0-9]{64}$/u; -const MAX_PATH_BYTES = 4_096; -const MAX_REFERENCE_BYTES = 512; -const MAX_DOCKER_OUTPUT_BYTES = 64 * 1_024; -const MAX_PREPARED_PLAN_BYTES = 128 * 1_024; - -export interface ResolveTargetConfigInput { - readonly allowRemotePull?: boolean; - readonly baseImage?: string; - readonly context?: string; - readonly dockerCommand?: string; - readonly evidenceDestination: string; - readonly execFile?: DockerTargetExecFile; - readonly preparedPlanPath?: string; - readonly pull?: boolean; - readonly signal?: AbortSignal; - readonly timeoutMs?: number; -} - -export interface TargetConfigResolution { - readonly base_image: { - readonly config_digest: `sha256:${string}`; - readonly reference: string; - }; - readonly endpoint: { - readonly class: "local" | "remote"; - readonly transport: "fd" | "http" | "https" | "npipe" | "ssh" | "tcp" | "unix"; - }; - readonly context_selection: "auto-local" | "explicit"; - readonly platform: { - readonly architecture: "amd64" | "arm64"; - readonly os: "linux"; - }; - readonly target_config: { - readonly context: string; - readonly dockerCommand: string; - readonly evidenceDestination: string; - readonly preparedArtifactMappings?: readonly PreparedArtifactMapping[]; - readonly timeoutMs: number; - readonly version: typeof TARGET_DEFAULT_CONFIG_STDIN_VERSION; - }; - readonly version: typeof TARGET_CONFIG_RESOLUTION_VERSION; -} - -const validationFailure = (message: string): never => { - throw new SpawnfileError("validation_error", message); -}; - -const runtimeFailure = (message: string): never => { - throw new SpawnfileError("runtime_error", message); -}; - -const parseContext = (value: unknown): string => - typeof value === "string" && CONTEXT.test(value) - ? value - : validationFailure("Docker context must be an explicit bounded context name"); - -const parseDockerCommand = (value: unknown): string => { - if (typeof value !== "string" || value.includes("\0") - || Buffer.byteLength(value, "utf8") > 1_024) { - return validationFailure("Docker command is invalid"); - } - if (COMMAND_NAME.test(value)) return value; - if (path.isAbsolute(value) && path.normalize(value) === value) return value; - return validationFailure("Docker command is invalid"); -}; - -const parseTimeout = (value: unknown): number => - typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 120_000 - ? value - : validationFailure("Target timeout must be an integer from 1 to 120000 milliseconds"); - -const parseBaseImage = (value: unknown): string => { - if (typeof value !== "string" || value !== value.trim() - || Buffer.byteLength(value, "utf8") > MAX_REFERENCE_BYTES - || CONFIG_DIGEST.test(value) || parseImageReference(value) === null) { - return validationFailure("Base image must be an explicit portable image reference"); - } - return value; -}; - -const validateEvidenceDestination = async (value: unknown): Promise => { - if (typeof value !== "string" || value.includes("\0") - || Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES - || !path.isAbsolute(value) || path.normalize(value) !== value) { - return validationFailure("Evidence destination must be an absolute normalized path"); - } - const parent = path.dirname(value); - const owner = process.getuid?.(); - try { - const parentInfo = await lstat(parent); - if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink() - || (parentInfo.mode & 0o777) !== 0o700 - || owner !== undefined && parentInfo.uid !== owner - || await realpath(parent) !== parent) { - return validationFailure("Evidence destination parent must be a private physical directory"); - } - try { - const destinationInfo = await lstat(value); - if (!destinationInfo.isFile() || destinationInfo.isSymbolicLink() - || (destinationInfo.mode & 0o777) !== 0o600 - || owner !== undefined && destinationInfo.uid !== owner) { - return validationFailure("Existing evidence destination must be a private regular file"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - } catch (error) { - if (error instanceof SpawnfileError) throw error; - return validationFailure("Evidence destination parent is unavailable"); - } - return value; -}; - -const parsePreparedPlanPath = (value: unknown): string => { - if (typeof value !== "string" || value.includes("\0") - || Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES - || !path.isAbsolute(value) || path.normalize(value) !== value) { - return validationFailure("Prepared target plan path must be absolute and normalized"); - } - return value; -}; - -const readPreparedPlan = async ( - planPath: string | undefined, - evidenceDestination: string -): Promise => { - if (planPath === undefined) return undefined; - const resolved = parsePreparedPlanPath(planPath); - const owner = process.getuid?.(); - let handle; - try { - const before = await lstat(resolved); - if (!before.isFile() || before.isSymbolicLink() || before.size < 1 - || before.size > MAX_PREPARED_PLAN_BYTES || (before.mode & 0o777) !== 0o600 - || owner !== undefined && before.uid !== owner) { - return validationFailure("Prepared target plan must be a private bounded regular file"); - } - handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW); - const bytes = await handle.readFile(); - const after = await handle.stat(); - if (after.dev !== before.dev || after.ino !== before.ino || after.size !== bytes.byteLength) { - return validationFailure("Prepared target plan changed while it was read"); - } - let source: string; - try { - source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); - } catch { - return validationFailure("Prepared target plan must be UTF-8 JSON"); - } - const parsed = JSON.parse(source) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) - || Object.keys(parsed).sort().join("\0") - !== "evidence_destination\0prepared_artifact_mapping") { - return validationFailure("Prepared target plan has an invalid shape"); - } - const value = parsed as Record; - if (value.evidence_destination !== evidenceDestination) { - return validationFailure("Prepared target plan evidence destination does not match"); - } - try { - return parseTargetPreparedArtifactMappings([value.prepared_artifact_mapping]); - } catch { - return validationFailure("Prepared target plan artifact mapping is invalid"); - } - } catch (error) { - if (error instanceof SpawnfileError) throw error; - return validationFailure("Prepared target plan is unavailable"); - } finally { - await handle?.close().catch(() => undefined); - } -}; - -type EndpointTransport = TargetConfigResolution["endpoint"]["transport"]; - -const classifyEndpoint = ( - endpoint: string -): TargetConfigResolution["endpoint"] => { - if (Buffer.byteLength(endpoint, "utf8") > 4_096 || /\s/u.test(endpoint)) { - return runtimeFailure("Docker context returned an invalid endpoint"); - } - const match = /^(fd|http|https|npipe|ssh|tcp|unix):\/\/.+$/u.exec(endpoint); - if (!match) return runtimeFailure("Docker context returned an unsupported endpoint transport"); - const transport = match[1] as EndpointTransport; - return Object.freeze({ - class: transport === "fd" || transport === "npipe" || transport === "unix" - ? "local" as const - : "remote" as const, - transport, - }); -}; - -const normalizeArchitecture = (value: unknown): "amd64" | "arm64" => { - if (typeof value !== "string") return runtimeFailure("Docker architecture is invalid"); - switch (value.trim()) { - case "amd64": - case "x64": - case "x86_64": - return "amd64"; - case "aarch64": - case "arm64": - return "arm64"; - default: - return runtimeFailure("Docker architecture is unsupported"); - } -}; - -const exactJson = ( - source: string, - keys: readonly string[], - failureMessage: string -): Record => { - if (Buffer.byteLength(source, "utf8") > MAX_DOCKER_OUTPUT_BYTES) { - return runtimeFailure(failureMessage); - } - try { - const parsed = JSON.parse(source) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) - || Object.keys(parsed).sort().join("\0") !== [...keys].sort().join("\0")) { - return runtimeFailure(failureMessage); - } - return parsed as Record; - } catch { - return runtimeFailure(failureMessage); - } -}; - -const executeDocker = async ( - execFile: DockerTargetExecFile, - command: string, - context: string, - args: string[], - timeout: number, - signal: AbortSignal | undefined, - failureMessage: string -): Promise => { - try { - const result = await execFile(command, ["--context", context, ...args], { signal, timeout }); - if (Buffer.byteLength(result.stdout, "utf8") > MAX_DOCKER_OUTPUT_BYTES) { - return runtimeFailure(failureMessage); - } - return result.stdout; - } catch { - return runtimeFailure(failureMessage); - } -}; - -const resolveCurrentDockerContext = async ( - execFile: DockerTargetExecFile, - dockerCommand: string, - timeoutMs: number, - signal: AbortSignal | undefined -): Promise => { - try { - const result = await execFile(dockerCommand, ["context", "show"], { - signal, - timeout: timeoutMs, - }); - if (Buffer.byteLength(result.stdout, "utf8") > 4_096) { - return runtimeFailure("Current Docker context is invalid"); - } - return parseContext(result.stdout.trim()); - } catch (error) { - if (error instanceof SpawnfileError) throw error; - return runtimeFailure("Unable to resolve the current Docker context"); - } -}; +export { + createCanonicalTargetConfigBytes, + createTargetConfigDigest, + createTargetConfigResolutionBytes, + STANDARD_WORLD_BASE_IMAGE, + TARGET_CONFIG_DIGEST_VERSION, + TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION, + TARGET_CONFIG_RESOLUTION_VERSION, + type ResolveTargetConfigDependencies, + type ResolveTargetConfigInput, + type TargetConfigResolution, +} from "./targetConfigResolverContracts.js"; export const resolveTargetConfig = async ( - input: ResolveTargetConfigInput + input: ResolveTargetConfigInput, + dependencies: ResolveTargetConfigDependencies = {}, ): Promise => { const baseImage = parseBaseImage(input.baseImage ?? STANDARD_WORLD_BASE_IMAGE); const dockerCommand = parseDockerCommand(input.dockerCommand ?? "docker"); const timeoutMs = parseTimeout(input.timeoutMs ?? 10_000); const evidenceDestination = await validateEvidenceDestination(input.evidenceDestination); - const preparedArtifactMappings = await readPreparedPlan( - input.preparedPlanPath, - evidenceDestination + const preparedArtifactMappings = await readTargetConfigPreparedPlan( + input.preparedPlanPath, evidenceDestination, ); const execFile = input.execFile ?? defaultDockerTargetExecFile; const contextSelection = input.context === undefined ? "auto-local" as const : "explicit" as const; @@ -317,9 +72,13 @@ export const resolveTargetConfig = async ( const endpoint = classifyEndpoint(endpointValue); if (contextSelection === "auto-local" && endpoint.class !== "local") { return validationFailure( - "Current Docker context is remote; pass --context explicitly to select a remote target" + "Current Docker context is remote; pass --context explicitly to select a remote target", ); } + if (input.prepareEvidenceHelper === true + && (contextSelection !== "explicit" || endpoint.class !== "local")) { + return validationFailure("Evidence helper preparation requires an explicitly selected local Docker context"); + } if (input.pull === true && endpoint.class === "remote" && input.allowRemotePull !== true) { return validationFailure("Pulling on a remote Docker context requires --allow-remote-pull"); } @@ -327,7 +86,7 @@ export const resolveTargetConfig = async ( const infoSource = await executeDocker( execFile, dockerCommand, context, ["info", "--format", "{\"Architecture\":{{json .Architecture}},\"OSType\":{{json .OSType}}}"], - timeoutMs, input.signal, "Unable to inspect Docker target platform" + timeoutMs, input.signal, "Unable to inspect Docker target platform", ); const info = exactJson(infoSource, ["Architecture", "OSType"], "Docker target platform is invalid"); if (info.OSType !== "linux") return runtimeFailure("Docker target operating system is unsupported"); @@ -336,7 +95,7 @@ export const resolveTargetConfig = async ( if (input.pull === true) { await executeDocker( execFile, dockerCommand, context, ["image", "pull", "--quiet", baseImage], - timeoutMs, input.signal, "Unable to pull the requested base image" + timeoutMs, input.signal, "Unable to pull the requested base image", ); } const imageSource = await executeDocker( @@ -346,39 +105,66 @@ export const resolveTargetConfig = async ( timeoutMs, input.signal, input.pull === true ? "Unable to inspect the pulled base image" - : "Base image is unavailable in the Docker context; rerun with --pull to fetch it" + : "Base image is unavailable in the Docker context; rerun with --pull to fetch it", ); const image = exactJson( - imageSource, ["Architecture", "Id", "Os"], "Docker base image inspection is invalid" + imageSource, ["Architecture", "Id", "Os"], "Docker base image inspection is invalid", ); const imageArchitecture = normalizeArchitecture(image.Architecture); if (image.Os !== "linux" || imageArchitecture !== architecture) { return runtimeFailure("Docker base image platform does not match the selected target"); } - if (typeof image.Id !== "string" || !CONFIG_DIGEST.test(image.Id)) { + if (typeof image.Id !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(image.Id)) { return runtimeFailure("Docker base image config ID is invalid"); } + let preparedEvidenceHelper: PreparedEvidenceHelperReceipt | undefined; + if (input.prepareEvidenceHelper === true) { + try { + preparedEvidenceHelper = parsePreparedEvidenceHelperReceipt( + await (dependencies.prepareEvidenceExportHelper ?? prepareEvidenceExportHelper)({ + baseImage, + context, + executor: input.execFile === undefined + ? createPreparedEvidenceHelperExecutor(dockerCommand) + : async (file, args, options) => { + if (file !== "docker") throw new Error("unexpected helper executable"); + return execFile(dockerCommand, args, { + signal: options.signal, + stdin: (options as { readonly stdin?: Uint8Array }).stdin, + timeout: options.timeout, + }); + }, + privateRoot: path.join(resolveSpawnfileHome(), "target", "evidence-helper"), + signal: input.signal, + timeoutMs, + }), + ); + } catch { + return runtimeFailure("Evidence helper preparation failed for the selected local target"); + } + } + const targetConfig = Object.freeze({ + context, dockerCommand, evidenceDestination, + ...(preparedEvidenceHelper === undefined ? {} : { + evidenceHelperBaseImage: baseImage, preparedEvidenceHelper, + }), + ...(preparedArtifactMappings === undefined ? {} : { preparedArtifactMappings }), + timeoutMs, + version: TARGET_DEFAULT_CONFIG_STDIN_VERSION, + }); return Object.freeze({ base_image: Object.freeze({ - config_digest: image.Id as `sha256:${string}`, - reference: baseImage, + config_digest: image.Id as `sha256:${string}`, reference: baseImage, }), context_selection: contextSelection, endpoint, platform: Object.freeze({ architecture, os: "linux" as const }), - target_config: Object.freeze({ - context, - dockerCommand, - evidenceDestination, - ...(preparedArtifactMappings === undefined ? {} : { preparedArtifactMappings }), - timeoutMs, - version: TARGET_DEFAULT_CONFIG_STDIN_VERSION, + ...(preparedEvidenceHelper === undefined ? {} : { + prepared_evidence_helper: preparedEvidenceHelper, }), + target_config: targetConfig, + target_config_digest: createTargetConfigDigest(targetConfig), version: TARGET_CONFIG_RESOLUTION_VERSION, }); }; - -export const createTargetConfigResolutionBytes = ( - resolution: TargetConfigResolution -): string => JSON.stringify(resolution); diff --git a/src/cli/targetConfigResolverCommand.ts b/src/cli/targetConfigResolverCommand.ts index f8b57de1..124f7454 100644 --- a/src/cli/targetConfigResolverCommand.ts +++ b/src/cli/targetConfigResolverCommand.ts @@ -23,6 +23,7 @@ interface TargetConfigResolverCommandOptions { readonly context?: string; readonly dockerCommand: string; readonly evidenceDestination: string; + readonly prepareEvidenceHelper?: boolean; readonly preparedPlan?: string; readonly pull?: boolean; readonly timeoutMs: string; @@ -59,6 +60,10 @@ export const registerTargetConfigResolverCommand = ( "Absolute private evidence archive destination" ) .option("--docker-command ", "Docker-compatible command", "docker") + .option( + "--prepare-evidence-helper", + "Prepare the package-owned evidence helper on an explicitly selected local Docker context", + ) .option("--timeout-ms ", "Bounded Docker command timeout", "10000") .option( "--prepared-plan ", @@ -77,6 +82,7 @@ export const registerTargetConfigResolverCommand = ( context: options.context, dockerCommand: options.dockerCommand, evidenceDestination: options.evidenceDestination, + prepareEvidenceHelper: options.prepareEvidenceHelper, preparedPlanPath: options.preparedPlan, pull: options.pull, timeoutMs: timeout(options.timeoutMs), diff --git a/src/cli/targetConfigResolverContracts.ts b/src/cli/targetConfigResolverContracts.ts new file mode 100644 index 00000000..6e5f5263 --- /dev/null +++ b/src/cli/targetConfigResolverContracts.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; + +import type { + PreparedEvidenceHelperReceipt, + PrepareEvidenceHelperInput, +} from "../evidenceExportHelper/index.js"; +import type { DockerTargetExecFile } from "../target/dockerTarget.js"; + +import type { PreparedArtifactMapping } from "./targetDefaultConfig.js"; +import { TARGET_DEFAULT_CONFIG_STDIN_VERSION } from "./targetDefaultConfigStdin.js"; +import { TARGET_CONFIG_PREPARED_PLAN_VERSION } from "./targetConfigPreparedPlan.js"; + +export const TARGET_CONFIG_RESOLUTION_VERSION = + "spawnfile.target-config-resolution.v1" as const; +export const TARGET_CONFIG_DIGEST_VERSION = + "spawnfile.target-config-digest.v1" as const; +/** Exact version for the strict `resolve_config --prepared-plan` document. */ +export const TARGET_CONFIG_PREPARED_PLAN_ABI_VERSION = TARGET_CONFIG_PREPARED_PLAN_VERSION; +export const STANDARD_WORLD_BASE_IMAGE = "node:22-bookworm-slim"; + +export interface ResolveTargetConfigInput { + readonly allowRemotePull?: boolean; + readonly baseImage?: string; + readonly context?: string; + readonly dockerCommand?: string; + readonly evidenceDestination: string; + readonly prepareEvidenceHelper?: boolean; + readonly execFile?: DockerTargetExecFile; + readonly preparedPlanPath?: string; + readonly pull?: boolean; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} +export interface TargetConfigResolution { + readonly base_image: { + readonly config_digest: `sha256:${string}`; + readonly reference: string; + }; + readonly endpoint: { + readonly class: "local" | "remote"; + readonly transport: "fd" | "http" | "https" | "npipe" | "ssh" | "tcp" | "unix"; + }; + readonly context_selection: "auto-local" | "explicit"; + readonly platform: { + readonly architecture: "amd64" | "arm64"; + readonly os: "linux"; + }; + readonly prepared_evidence_helper?: PreparedEvidenceHelperReceipt; + readonly target_config: { + readonly context: string; + readonly dockerCommand: string; + readonly evidenceDestination: string; + readonly evidenceHelperBaseImage?: string; + readonly preparedEvidenceHelper?: PreparedEvidenceHelperReceipt; + readonly preparedArtifactMappings?: readonly PreparedArtifactMapping[]; + readonly timeoutMs: number; + readonly version: typeof TARGET_DEFAULT_CONFIG_STDIN_VERSION; + }; + readonly target_config_digest: `sha256:${string}`; + readonly version: typeof TARGET_CONFIG_RESOLUTION_VERSION; +} +export interface ResolveTargetConfigDependencies { + readonly prepareEvidenceExportHelper?: ( + input: PrepareEvidenceHelperInput, + ) => Promise; +} + +const canonicalJson = (value: unknown): string => { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Target configuration is not JSON"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value !== "object") throw new TypeError("Target configuration is not JSON"); + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +}; + +export const createCanonicalTargetConfigBytes = ( + targetConfig: TargetConfigResolution["target_config"], +): string => canonicalJson(targetConfig); +export const createTargetConfigDigest = ( + targetConfig: TargetConfigResolution["target_config"], +): `sha256:${string}` => `sha256:${createHash("sha256") + .update(`${TARGET_CONFIG_DIGEST_VERSION}\0`, "utf8") + .update(createCanonicalTargetConfigBytes(targetConfig), "utf8") + .digest("hex")}`; +export const createTargetConfigResolutionBytes = ( + resolution: TargetConfigResolution, +): string => JSON.stringify(resolution); diff --git a/src/cli/targetConfigResolverDocker.ts b/src/cli/targetConfigResolverDocker.ts new file mode 100644 index 00000000..5ed91e89 --- /dev/null +++ b/src/cli/targetConfigResolverDocker.ts @@ -0,0 +1,66 @@ +import { SpawnfileError } from "../shared/index.js"; +import type { DockerTargetExecFile } from "../target/dockerTarget.js"; + +import { parseContext, runtimeFailure } from "./targetConfigResolverValidation.js"; + +const MAX_DOCKER_OUTPUT_BYTES = 64 * 1_024; + +export const exactJson = ( + source: string, + keys: readonly string[], + failureMessage: string, +): Record => { + if (Buffer.byteLength(source, "utf8") > MAX_DOCKER_OUTPUT_BYTES) { + return runtimeFailure(failureMessage); + } + try { + const parsed = JSON.parse(source) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) + || Object.keys(parsed).sort().join("\0") !== [...keys].sort().join("\0")) { + return runtimeFailure(failureMessage); + } + return parsed as Record; + } catch { + return runtimeFailure(failureMessage); + } +}; + +export const executeDocker = async ( + execFile: DockerTargetExecFile, + command: string, + context: string, + args: string[], + timeout: number, + signal: AbortSignal | undefined, + failureMessage: string, +): Promise => { + try { + const result = await execFile(command, ["--context", context, ...args], { signal, timeout }); + if (Buffer.byteLength(result.stdout, "utf8") > MAX_DOCKER_OUTPUT_BYTES) { + return runtimeFailure(failureMessage); + } + return result.stdout; + } catch { + return runtimeFailure(failureMessage); + } +}; + +export const resolveCurrentDockerContext = async ( + execFile: DockerTargetExecFile, + dockerCommand: string, + timeoutMs: number, + signal: AbortSignal | undefined, +): Promise => { + try { + const result = await execFile(dockerCommand, ["context", "show"], { + signal, timeout: timeoutMs, + }); + if (Buffer.byteLength(result.stdout, "utf8") > 4_096) { + return runtimeFailure("Current Docker context is invalid"); + } + return parseContext(result.stdout.trim()); + } catch (error) { + if (error instanceof SpawnfileError) throw error; + return runtimeFailure("Unable to resolve the current Docker context"); + } +}; diff --git a/src/cli/targetConfigResolverValidation.test.ts b/src/cli/targetConfigResolverValidation.test.ts new file mode 100644 index 00000000..a67d432e --- /dev/null +++ b/src/cli/targetConfigResolverValidation.test.ts @@ -0,0 +1,111 @@ +import os from "node:os"; +import path from "node:path"; +import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + classifyEndpoint, + normalizeArchitecture, + parseBaseImage, + parseContext, + parseDockerCommand, + parseTimeout, + validateEvidenceDestination +} from "./targetConfigResolverValidation.js"; + +const roots: string[] = []; +const privateRoot = async (): Promise => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-target-validation-"))); + roots.push(root); + await chmod(root, 0o700); + return root; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("target config resolver validation", () => { + it("accepts only bounded context, command, timeout, and image grammar", () => { + expect(parseContext("prod_1")).toBe("prod_1"); + for (const invalid of [null, "Prod", "a".repeat(65)]) { + expect(() => parseContext(invalid)).toThrow(/context/u); + } + + expect(parseDockerCommand("docker-compatible")).toBe("docker-compatible"); + expect(parseDockerCommand("/opt/docker/bin/docker")).toBe("/opt/docker/bin/docker"); + for (const invalid of [null, "docker\0hostile", "relative/docker", "x".repeat(1_025)]) { + expect(() => parseDockerCommand(invalid)).toThrow(/command/u); + } + + for (const valid of [1, 120_000]) expect(parseTimeout(valid)).toBe(valid); + for (const invalid of [0, 120_001, 1.5, "1000"]) { + expect(() => parseTimeout(invalid)).toThrow(/timeout/u); + } + expect(parseBaseImage("node:24-bookworm-slim")).toBe("node:24-bookworm-slim"); + expect(() => parseBaseImage("not an image")).toThrow(/portable image/u); + }); + + it("classifies every supported Docker transport and architecture alias", () => { + for (const transport of ["fd", "npipe", "unix"] as const) { + expect(classifyEndpoint(`${transport}://endpoint`)).toEqual({ class: "local", transport }); + } + for (const transport of ["http", "https", "ssh", "tcp"] as const) { + expect(classifyEndpoint(`${transport}://endpoint`)).toEqual({ class: "remote", transport }); + } + for (const invalid of ["socket", "ssh://bad endpoint", `unix://${"x".repeat(4_100)}`]) { + expect(() => classifyEndpoint(invalid)).toThrow(/endpoint|transport/u); + } + + for (const value of ["amd64", "x64", "x86_64"]) expect(normalizeArchitecture(value)).toBe("amd64"); + for (const value of ["aarch64", "arm64"]) expect(normalizeArchitecture(value)).toBe("arm64"); + expect(() => normalizeArchitecture(null)).toThrow(/invalid/u); + expect(() => normalizeArchitecture("riscv64")).toThrow(/unsupported/u); + }); + + it("accepts a missing or private regular destination under one physical private parent", async () => { + const root = await privateRoot(); + const destination = path.join(root, "evidence.tar"); + await expect(validateEvidenceDestination(destination)).resolves.toBe(destination); + await writeFile(destination, "evidence", { mode: 0o600 }); + await chmod(destination, 0o600); + await expect(validateEvidenceDestination(destination)).resolves.toBe(destination); + }); + + it("rejects lexical, parent, and existing-destination filesystem substitution", async () => { + for (const invalid of [null, "relative.tar", "/tmp/../tmp/evidence.tar", `/tmp/${"x".repeat(4_100)}`, "/tmp/bad\0name"]) { + await expect(validateEvidenceDestination(invalid)).rejects.toThrow(/absolute normalized path/u); + } + + const root = await privateRoot(); + const missingParent = path.join(root, "missing", "evidence.tar"); + await expect(validateEvidenceDestination(missingParent)).rejects.toThrow(/unavailable/u); + + const publicParent = path.join(root, "public"); + await mkdir(publicParent, { mode: 0o755 }); + await chmod(publicParent, 0o755); + await expect(validateEvidenceDestination(path.join(publicParent, "evidence.tar"))) + .rejects.toThrow(/private physical directory/u); + + const physicalParent = path.join(root, "physical"); + const linkedParent = path.join(root, "linked"); + await mkdir(physicalParent, { mode: 0o700 }); + await symlink(physicalParent, linkedParent); + await expect(validateEvidenceDestination(path.join(linkedParent, "evidence.tar"))) + .rejects.toThrow(/private physical directory/u); + + const directoryDestination = path.join(root, "directory-destination"); + await mkdir(directoryDestination, { mode: 0o700 }); + await expect(validateEvidenceDestination(directoryDestination)).rejects.toThrow(/private regular file/u); + + const publicFile = path.join(root, "public-file"); + await writeFile(publicFile, "evidence", { mode: 0o644 }); + await chmod(publicFile, 0o644); + await expect(validateEvidenceDestination(publicFile)).rejects.toThrow(/private regular file/u); + + const linkedFile = path.join(root, "linked-file"); + await symlink(publicFile, linkedFile); + await expect(validateEvidenceDestination(linkedFile)).rejects.toThrow(/private regular file/u); + }); +}); diff --git a/src/cli/targetConfigResolverValidation.ts b/src/cli/targetConfigResolverValidation.ts new file mode 100644 index 00000000..626b2eb0 --- /dev/null +++ b/src/cli/targetConfigResolverValidation.ts @@ -0,0 +1,93 @@ +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { SpawnfileError } from "../shared/index.js"; +import { parseDockerBaseImageReference } from "../target/dockerBaseImage.js"; + +import type { TargetConfigResolution } from "./targetConfigResolverContracts.js"; + +const CONTEXT = /^[a-z][a-z0-9_-]{0,63}$/u; +const COMMAND_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const MAX_PATH_BYTES = 4_096; + +export const validationFailure = (message: string): never => { + throw new SpawnfileError("validation_error", message); +}; +export const runtimeFailure = (message: string): never => { + throw new SpawnfileError("runtime_error", message); +}; +export const parseContext = (value: unknown): string => + typeof value === "string" && CONTEXT.test(value) + ? value + : validationFailure("Docker context must be an explicit bounded context name"); +export const parseDockerCommand = (value: unknown): string => { + if (typeof value !== "string" || value.includes("\0") + || Buffer.byteLength(value, "utf8") > 1_024) { + return validationFailure("Docker command is invalid"); + } + if (COMMAND_NAME.test(value)) return value; + if (path.isAbsolute(value) && path.normalize(value) === value) return value; + return validationFailure("Docker command is invalid"); +}; +export const parseTimeout = (value: unknown): number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 120_000 + ? value + : validationFailure("Target timeout must be an integer from 1 to 120000 milliseconds"); +export const parseBaseImage = (value: unknown): string => parseDockerBaseImageReference(value) + ?? validationFailure("Base image must be an explicit portable image reference"); + +export const validateEvidenceDestination = async (value: unknown): Promise => { + if (typeof value !== "string" || value.includes("\0") + || Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES + || !path.isAbsolute(value) || path.normalize(value) !== value) { + return validationFailure("Evidence destination must be an absolute normalized path"); + } + const parent = path.dirname(value); + const owner = process.getuid?.(); + try { + const parentInfo = await lstat(parent); + if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink() + || (parentInfo.mode & 0o777) !== 0o700 + || owner !== undefined && parentInfo.uid !== owner + || await realpath(parent) !== parent) { + return validationFailure("Evidence destination parent must be a private physical directory"); + } + try { + const destinationInfo = await lstat(value); + if (!destinationInfo.isFile() || destinationInfo.isSymbolicLink() + || (destinationInfo.mode & 0o777) !== 0o600 + || owner !== undefined && destinationInfo.uid !== owner) { + return validationFailure("Existing evidence destination must be a private regular file"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } catch (error) { + if (error instanceof SpawnfileError) throw error; + return validationFailure("Evidence destination parent is unavailable"); + } + return value; +}; + +type EndpointTransport = TargetConfigResolution["endpoint"]["transport"]; +export const classifyEndpoint = (endpoint: string): TargetConfigResolution["endpoint"] => { + if (Buffer.byteLength(endpoint, "utf8") > 4_096 || /\s/u.test(endpoint)) { + return runtimeFailure("Docker context returned an invalid endpoint"); + } + const match = /^(fd|http|https|npipe|ssh|tcp|unix):\/\/.+$/u.exec(endpoint); + if (!match) return runtimeFailure("Docker context returned an unsupported endpoint transport"); + const transport = match[1] as EndpointTransport; + return Object.freeze({ + class: transport === "fd" || transport === "npipe" || transport === "unix" + ? "local" as const : "remote" as const, + transport, + }); +}; +export const normalizeArchitecture = (value: unknown): "amd64" | "arm64" => { + if (typeof value !== "string") return runtimeFailure("Docker architecture is invalid"); + switch (value.trim()) { + case "amd64": case "x64": case "x86_64": return "amd64"; + case "aarch64": case "arm64": return "arm64"; + default: return runtimeFailure("Docker architecture is unsupported"); + } +}; diff --git a/src/cli/targetDefaultAuthorities.ts b/src/cli/targetDefaultAuthorities.ts index a24910f5..d888be84 100644 --- a/src/cli/targetDefaultAuthorities.ts +++ b/src/cli/targetDefaultAuthorities.ts @@ -1,4 +1,6 @@ import { initializeDockerArtifactIdentityStore, type DockerArtifactIdentityBinding, type DockerArtifactIdentityStore, type DockerArtifactMapping } from "../target/dockerArtifactsProvider.js"; +import type { DockerArtifactExecutor } from "../target/dockerArtifactsProvider.js"; +import { createPreparedEvidenceHelperExecutor } from "../evidenceExportHelper/index.js"; import { createDockerTargetExecutors, type DockerTargetExecutors } from "../target/dockerCommandExecutor.js"; import { initializeTargetSecretVersionAuthorityStore, type TargetSecretVersionAuthorityStore } from "../target/dockerSecretsAuthority.js"; import { @@ -65,6 +67,7 @@ export interface TargetDefaultAuthorities { readonly evidenceExportAuthorityStore: EvidenceExportAuthorityStore; readonly executors: DockerTargetExecutors; readonly handoffResolver: OrganizationAttachmentResolver; + readonly helperExecutor: DockerArtifactExecutor; /** Omitted when this invocation cannot export evidence. */ readonly helperArtifactResolver?: HelperArtifactResolver; readonly journals: TargetJournalResolver; @@ -117,6 +120,7 @@ export const initializeTargetDefaultAuthoritySession = async ( handoffAuthority = await initializeOrganizationHandoffAuthorityStore(); const handoffResolver = handoffAuthority.resolver as OrganizationAttachmentResolver; const executors = createDockerTargetExecutors({ dockerCommand: config.dockerCommand }); + const helperExecutor = createPreparedEvidenceHelperExecutor(config.dockerCommand); const preparedBuilder = createDockerTargetLocalBundleBuilder({ context: config.context, executor: executors.artifact, timeoutMs: config.timeoutMs }); const preparedStore = await initializeFilesystemTargetLocalBundleStore(config.paths.containerBundles); @@ -258,6 +262,7 @@ export const initializeTargetDefaultAuthoritySession = async ( evidenceExportAuthorityStore, executors, handoffResolver, + helperExecutor, ...(helperArtifactResolver ? { helperArtifactResolver } : {}), journals, secretAuthorityStore, diff --git a/src/cli/targetDefaultConfig.test.ts b/src/cli/targetDefaultConfig.test.ts index bfe28391..3b0d6ae9 100644 --- a/src/cli/targetDefaultConfig.test.ts +++ b/src/cli/targetDefaultConfig.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { parsePreparedEvidenceHelperReceipt } from "../evidenceExportHelper/index.js"; import { TARGET_DEFAULT_CONFIG_ERROR, loadTargetDefaultConfig, @@ -13,6 +14,11 @@ import { const manifest = `sha256:${"a".repeat(64)}`; const image = `sha256:${"b".repeat(64)}`; +const preparedHelper = parsePreparedEvidenceHelperReceipt({ + digest: `sha256:${"c".repeat(64)}`, + handle: `opaque_${"d".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", +}); const mapping = { artifact_manifest_digest: manifest, image_digest: image, @@ -75,6 +81,7 @@ describe("private target default configuration", () => { attachmentAuthority: path.join(value.home, "target", "attachment-authority"), worldAuthority: path.join(value.home, "target", "world-authority"), evidenceExport: path.join(value.home, "target", "evidence-export"), + evidenceHelper: path.join(value.home, "target", "evidence-helper"), containerBundles: path.join(value.home, "target", "container-bundles") }); for (const directory of Object.values(config.paths)) { @@ -101,6 +108,28 @@ describe("private target default configuration", () => { expect(JSON.stringify(config)).not.toContain("helperArtifact"); }); + it("accepts the local helper only as a paired opaque prepared receipt", async () => { + const value = await setup(); + const { artifactMappings: _mappings, helperArtifactManifestDigest: _legacy, ...helperFree } = value.inputs; + const config = await loadTargetDefaultConfig({ + ...helperFree, + evidenceHelperBaseImage: "node:22-bookworm-slim", + preparedEvidenceHelper: preparedHelper, + }); + expect(config.evidenceHelperBaseImage).toBe("node:22-bookworm-slim"); + expect(config.preparedEvidenceHelper).toEqual(preparedHelper); + for (const partial of [ + { ...helperFree, evidenceHelperBaseImage: "node:22-bookworm-slim" }, + { ...helperFree, preparedEvidenceHelper: preparedHelper }, + { ...helperFree, evidenceHelperBaseImage: "sha256:" + "e".repeat(64), preparedEvidenceHelper: preparedHelper }, + { ...helperFree, evidenceHelperBaseImage: "node:22-bookworm-slim", preparedEvidenceHelper: { + ...preparedHelper, handle: "opaque_short", + } }, + ]) { + await expect(loadTargetDefaultConfig(partial as never)).rejects.toThrow(TARGET_DEFAULT_CONFIG_ERROR); + } + }); + it("keeps an explicit container-bundle authority across fresh per-run homes only", async () => { const first = await setup(); const durable = path.join(path.dirname(first.home), "durable-container-bundles"); diff --git a/src/cli/targetDefaultConfig.ts b/src/cli/targetDefaultConfig.ts index 8e696a58..d606c358 100644 --- a/src/cli/targetDefaultConfig.ts +++ b/src/cli/targetDefaultConfig.ts @@ -4,6 +4,11 @@ import path from "node:path"; import { types as nodeTypes } from "node:util"; import { resolveSpawnfileHome } from "../auth/index.js"; +import { + parsePreparedEvidenceHelperReceipt, + type PreparedEvidenceHelperReceipt, +} from "../evidenceExportHelper/index.js"; +import { parseDockerBaseImageReference } from "../target/dockerBaseImage.js"; import { parseDockerArtifactMappings, type DockerArtifactMapping @@ -21,7 +26,8 @@ const CHILD_ROOTS = Object.freeze([ "secret-authority", "attachment-authority", "world-authority", - "evidence-export" + "evidence-export", + "evidence-helper" ] as const); export interface TargetDefaultConfigInputs { @@ -37,6 +43,8 @@ export interface TargetDefaultConfigInputs { readonly containerBundleStoreRoot?: string; readonly context: string; readonly dockerCommand: string; + readonly evidenceHelperBaseImage?: string; + readonly preparedEvidenceHelper?: PreparedEvidenceHelperReceipt; readonly evidenceDestination: string; readonly helperArtifactManifestDigest?: string; /** Local-Daemon prepared images, admitted only by manifest/bundle/policy. */ @@ -60,6 +68,8 @@ export interface TargetDefaultConfig { readonly artifactMappings: readonly DockerArtifactMapping[]; readonly context: string; readonly dockerCommand: string; + readonly evidenceHelperBaseImage?: string; + readonly preparedEvidenceHelper?: PreparedEvidenceHelperReceipt; readonly evidenceDestination: string; /** Present only when the private evidence-export helper was configured. */ readonly helperArtifact?: DockerArtifactMapping; @@ -68,6 +78,7 @@ export interface TargetDefaultConfig { readonly artifactIdentities: string; readonly attachmentAuthority: string; readonly evidenceExport: string; + readonly evidenceHelper: string; readonly containerBundles: string; readonly journals: string; readonly root: string; @@ -90,7 +101,7 @@ const descriptorValues = (raw: unknown): Record => { if (!raw || typeof raw !== "object" || Array.isArray(raw) || nodeTypes.isProxy(raw) || Object.getPrototypeOf(raw) !== Object.prototype) return fail(); const expected = [ - "artifactMappings", "containerBundleStoreRoot", "context", "dockerCommand", "evidenceDestination", + "artifactMappings", "containerBundleStoreRoot", "context", "dockerCommand", "evidenceDestination", "evidenceHelperBaseImage", "preparedEvidenceHelper", "helperArtifactManifestDigest", "preparedArtifactMappings", "timeoutMs" ]; const required = ["context", "dockerCommand", "evidenceDestination", "timeoutMs"]; @@ -229,7 +240,7 @@ const prepareRoots = async ( const created = await Promise.all(childRoots.map((name) => ensureOwnedRoot(path.join(root, name)))); const containerBundles = containerBundleStoreRoot === undefined - ? created[6]! + ? created[7]! : await prepareExplicitContainerBundleRoot(containerBundleStoreRoot, root); return Object.freeze({ root, @@ -239,6 +250,7 @@ const prepareRoots = async ( attachmentAuthority: created[3]!, worldAuthority: created[4]!, evidenceExport: created[5]!, + evidenceHelper: created[6]!, containerBundles }); }; @@ -277,6 +289,11 @@ export const loadTargetDefaultConfig = async ( (typeof value.helperArtifactManifestDigest !== "string" || !DIGEST.test(value.helperArtifactManifestDigest)))) return fail(); const dockerCommand = command(value.dockerCommand); + const evidenceHelperBaseImage = value.evidenceHelperBaseImage === undefined ? undefined + : parseDockerBaseImageReference(value.evidenceHelperBaseImage) ?? fail(); + const preparedEvidenceHelper = value.preparedEvidenceHelper === undefined ? undefined + : (() => { try { return parsePreparedEvidenceHelperReceipt(value.preparedEvidenceHelper); } catch { return fail(); } })(); + if ((evidenceHelperBaseImage === undefined) !== (preparedEvidenceHelper === undefined)) return fail(); let artifactMappings: readonly DockerArtifactMapping[]; try { artifactMappings = value.artifactMappings === undefined ? Object.freeze([]) : parseDockerArtifactMappings(value.artifactMappings); } @@ -295,6 +312,8 @@ export const loadTargetDefaultConfig = async ( artifactMappings, context: value.context, dockerCommand, + ...(evidenceHelperBaseImage === undefined ? {} : { evidenceHelperBaseImage }), + ...(preparedEvidenceHelper === undefined ? {} : { preparedEvidenceHelper }), evidenceDestination, ...(matches.length === 1 ? { helperArtifact: matches[0]! } : {}), preparedArtifactMappings, diff --git a/src/cli/targetDefaultConfigStdin.test.ts b/src/cli/targetDefaultConfigStdin.test.ts index 7cfa6ce2..a0fec11a 100644 --- a/src/cli/targetDefaultConfigStdin.test.ts +++ b/src/cli/targetDefaultConfigStdin.test.ts @@ -46,6 +46,11 @@ const config = (destination: string) => ({ helperArtifactManifestDigest: `sha256:${"a".repeat(64)}`, timeoutMs: 30_000, version: TARGET_DEFAULT_CONFIG_STDIN_VERSION }); +const preparedHelper = Object.freeze({ + digest: `sha256:${"c".repeat(64)}`, + handle: `opaque_${"d".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", +}); const setup = async (): Promise => { const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-target-config-stdin-"))); roots.push(root); @@ -95,6 +100,23 @@ describe("readTargetDefaultConfigStdin", () => { .rejects.toThrow(TARGET_DEFAULT_CONFIG_STDIN_ERROR); }); + it("accepts the paired local prepared receipt but no caller-owned authority seam", async () => { + const value = JSON.parse(await setup()) as Record; + delete value.artifactMappings; + delete value.helperArtifactManifestDigest; + value.evidenceHelperBaseImage = "node:22-bookworm-slim"; + value.preparedEvidenceHelper = preparedHelper; + await expect(readTargetDefaultConfigStdin(stdin(JSON.stringify(value)))) + .resolves.toMatchObject({ evidenceHelperBaseImage: "node:22-bookworm-slim", preparedEvidenceHelper: preparedHelper }); + const partial = { ...value }; + delete partial.preparedEvidenceHelper; + await expect(readTargetDefaultConfigStdin(stdin(JSON.stringify(partial)))) + .rejects.toThrow(TARGET_DEFAULT_CONFIG_STDIN_ERROR); + await expect(readTargetDefaultConfigStdin(stdin(JSON.stringify({ + ...value, evidenceHelperAuthority: "/caller/owned/authority.json", + })))).rejects.toThrow(TARGET_DEFAULT_CONFIG_STDIN_ERROR); + }); + it("rejects empty, non-stdin bytes, BOM, malformed, trailing, and oversized input", async () => { await expect(readTargetDefaultConfigStdin((async function* () {})())).rejects.toThrow(TARGET_DEFAULT_CONFIG_STDIN_ERROR); await expect(readTargetDefaultConfigStdin((async function* () { yield 1; })())).rejects.toThrow(TARGET_DEFAULT_CONFIG_STDIN_ERROR); diff --git a/src/cli/targetDefaultConfigStdin.ts b/src/cli/targetDefaultConfigStdin.ts index c8649365..d022df14 100644 --- a/src/cli/targetDefaultConfigStdin.ts +++ b/src/cli/targetDefaultConfigStdin.ts @@ -70,7 +70,7 @@ const exactDefaultConfigObject = (raw: unknown): Record => { if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.getPrototypeOf(raw) !== Object.prototype) return fail(); const expected = [ - "artifactMappings", "container_bundle_store_root", "context", "dockerCommand", "evidenceDestination", + "artifactMappings", "container_bundle_store_root", "context", "dockerCommand", "evidenceDestination", "evidenceHelperBaseImage", "preparedEvidenceHelper", "helperArtifactManifestDigest", "preparedArtifactMappings", "timeoutMs", "version" ]; const required = ["context", "dockerCommand", "evidenceDestination", "timeoutMs", "version"]; @@ -155,6 +155,12 @@ const defaultConfigInputs = ( context: raw.context as string, dockerCommand: raw.dockerCommand as string, evidenceDestination: raw.evidenceDestination as string, + ...(Object.hasOwn(raw, "evidenceHelperBaseImage") ? { + evidenceHelperBaseImage: raw.evidenceHelperBaseImage as string + } : {}), + ...(Object.hasOwn(raw, "preparedEvidenceHelper") ? { + preparedEvidenceHelper: raw.preparedEvidenceHelper as TargetDefaultConfigInputs["preparedEvidenceHelper"] + } : {}), ...(Object.hasOwn(raw, "helperArtifactManifestDigest") ? { helperArtifactManifestDigest: raw.helperArtifactManifestDigest as string } : {}), diff --git a/src/cli/targetDefaultHandlerFactory.preparedHelper.test.ts b/src/cli/targetDefaultHandlerFactory.preparedHelper.test.ts new file mode 100644 index 00000000..e310fbbc --- /dev/null +++ b/src/cli/targetDefaultHandlerFactory.preparedHelper.test.ts @@ -0,0 +1,128 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { prepareEvidenceExportHelper } from "../evidenceExportHelper/index.js"; +import { parseTargetResourceRequest } from "../target/contracts.js"; +import { DockerArtifactProviderError } from "../target/dockerArtifactsProvider.js"; +import type { TargetDefaultAuthorities } from "./targetDefaultAuthorities.js"; +import type { TargetDefaultConfig } from "./targetDefaultConfig.js"; +import { + createTargetDefaultHandlers, + type TargetDefaultHandlerFactories, +} from "./targetDefaultHandlerFactory.js"; + +const roots: string[] = []; +const digest = (value: string): `sha256:${string}` => `sha256:${value.repeat(64)}`; +const base = digest("a"); +const helper = digest("b"); +const parsedRequest = parseTargetResourceRequest({ + descriptor_digest: digest("c"), + evidence_volume_handle: "opaque_evidencevolume01", + expected_revision: 7, + idempotency_key: "idem_exportevidence01", + operation: "export_evidence_volume" as const, + run_id: "run-one", + selected_target: { fingerprint: `sha256:${"d".repeat(32)}`, handle: "opaque_selectedtarget01" }, + version: "spawnfile.target-resource.request.v1" as const, +}); +if (parsedRequest.operation !== "export_evidence_volume") throw new Error("invalid test fixture"); +const request = parsedRequest; + +const helperConfig = Object.freeze({ + Cmd: [], Entrypoint: ["/bin/spawnfile-export-helper"], + Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], ExposedPorts: null, + Healthcheck: null, Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, + User: "65534:65534", Volumes: null, +}); + +const docker = () => { + const images = new Map([ + ["node:22-bookworm-slim", { config: base, helper: false }], + ]); + const executor = vi.fn(async (_file: string, args: string[]) => { + const command = args.slice(2); + if (command[0] === "context") return { stderr: "", stdout: JSON.stringify("unix:///tmp/docker.sock") }; + if (command[0] === "info") return { stderr: "", stdout: JSON.stringify({ + Architecture: "arm64", DockerRootDir: "/var/lib/docker", OSType: "linux", ServerVersion: "27.0", + }) }; + if (command[0] === "image" && command[1] === "inspect") { + const image = images.get(command[2]!); + if (!image) throw new DockerArtifactProviderError("image_not_found"); + const format = command[command.indexOf("--format") + 1]!; + return { stderr: "", stdout: JSON.stringify([format.includes("Config") ? { + Architecture: "arm64", Config: image.helper ? helperConfig : {}, Id: image.config, Os: "linux", + } : { Architecture: "arm64", Id: image.config, Os: "linux" }]) }; + } + if (command[0] === "build") { + images.set(helper, { config: helper, helper: true }); + expect(command).not.toContain("--tag"); + return { stderr: "", stdout: `${helper}\n` }; + } + throw new Error(`unexpected Docker command: ${command.join(" ")}`); + }); + return { executor, images }; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("prepared local helper target handoff", () => { + it("re-attests the opaque receipt inside the target before constructing evidence operations", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "prepared-target-handler-"))); + roots.push(root); + const privateRoot = path.join(root, "helper-authority"); + const targetDocker = docker(); + const receipt = await prepareEvidenceExportHelper({ + baseImage: "node:22-bookworm-slim", context: "local_dev", + executor: targetDocker.executor, privateRoot, + }); + const evidence = vi.fn(() => ({ execute: vi.fn(async () => ({ receipt: {}, receiptBytes: "exact" })) })); + const operation = vi.fn(() => ({ execute: vi.fn(async () => ({ receipt: {}, receiptBytes: "exact" })) })); + const regularArtifact = vi.fn(async () => { throw new Error("wrong helper executor"); }); + const factories: TargetDefaultHandlerFactories = { + artifact: operation, attachment: operation, cleanup: operation, evidence: evidence as never, + resource: operation, secret: operation, select: vi.fn(), world: operation, + }; + const authorities = { + artifactIdentityStore: {}, attachmentAuthorityStore: {}, evidenceExportAuthorityStore: {}, + executors: { + artifact: regularArtifact, attachment: operation, evidenceExport: operation, + publicArtifact: operation, resource: operation, secret: operation, world: operation, + }, + handoffResolver: { resolve: vi.fn() }, + helperExecutor: targetDocker.executor, + journals: { resolve: vi.fn(async ({ request: raw }) => ({ + journal: { withLifecycleLease: async (run: () => Promise) => run() }, + request: raw, + selectedTarget: {}, + })) }, + secretAuthorityStore: {}, secretResolver: { resolve: vi.fn() }, + topologyAttestor: { activate: vi.fn(), attest: vi.fn() }, + worldAuthorityStore: {}, worldResolver: { resolve: vi.fn() }, + } as unknown as TargetDefaultAuthorities; + const config = { + artifactMappings: [], context: "local_dev", dockerCommand: "docker", evidenceDestination: "/private/evidence.tar", + evidenceHelperBaseImage: "node:22-bookworm-slim", paths: { evidenceHelper: privateRoot }, + preparedArtifactMappings: [], preparedEvidenceHelper: receipt, timeoutMs: 120_000, + } as unknown as TargetDefaultConfig; + + const handlers = await createTargetDefaultHandlers(config, factories, authorities); + await handlers.export_evidence_volume(request); + + expect(targetDocker.executor.mock.calls.filter(([, args]) => args.includes("build"))).toHaveLength(1); + expect(regularArtifact).not.toHaveBeenCalled(); + expect(evidence).toHaveBeenCalledWith(expect.objectContaining({ + helperArtifactManifestDigest: receipt.digest, + localHelper: { + artifactManifestDigest: receipt.digest, + image_digest: helper, + image_reference: helper, + result_handle: receipt.handle, + }, + })); + }); +}); diff --git a/src/cli/targetDefaultHandlerFactory.ts b/src/cli/targetDefaultHandlerFactory.ts index 270a3d7d..fd7d30c8 100644 --- a/src/cli/targetDefaultHandlerFactory.ts +++ b/src/cli/targetDefaultHandlerFactory.ts @@ -10,7 +10,8 @@ import { createDockerSecretOperations } from "../target/dockerSecrets.js"; import { selectTarget, type SelectTargetOptions } from "../target/dockerTarget.js"; import { createDockerWorldServiceOperations } from "../target/dockerWorldService.js"; import { createEvidenceExportOperations } from "../target/evidenceExport.js"; -import { EVIDENCE_EXPORT_HELPER_CONTRACT } from "../target/evidenceExportProvider.js"; +import { createEvidenceExportHelper, EVIDENCE_EXPORT_HELPER_CONTRACT } from "../target/evidenceExportProvider.js"; +import { resolvePreparedEvidenceHelperImage } from "../evidenceExportHelper/index.js"; import { createDockerOrganizationAttachmentOperations } from "../target/organizationAttachment.js"; import type { SelectedTargetReceipt, TargetResourceRequest } from "../target/contracts.js"; @@ -106,7 +107,7 @@ const requireAuthorities = (raw: TargetDefaultAuthorities): TargetDefaultAuthori const names = [ "artifactIdentityStore", "attachmentAuthorityStore", "evidenceExportAuthorityStore", "executors", "handoffResolver", - "helperArtifactResolver", "journals", "secretAuthorityStore", + "helperArtifactResolver", "helperExecutor", "journals", "secretAuthorityStore", "secretResolver", "topologyAttestor", "worldAuthorityStore", "worldResolver" ] as const; if (!raw || typeof raw !== "object" || Array.isArray(raw) || nodeTypes.isProxy(raw) @@ -132,6 +133,9 @@ const requireAuthorities = (raw: TargetDefaultAuthorities): TargetDefaultAuthori if (executorNames.some((name) => typeof executors[name] !== "function")) { throw new Error("Target handler initialization failed"); } + if (typeof values.helperExecutor !== "function") { + throw new Error("Target handler initialization failed"); + } for (const name of [ "handoffResolver", "journals", "secretResolver", "worldResolver" ] as const) { @@ -222,6 +226,23 @@ const execute = async ( }).execute(request); case "export_evidence_volume": case "recover_operation": { + if (config.evidenceHelperBaseImage && config.preparedEvidenceHelper) { + const helperInput = { baseImage: config.evidenceHelperBaseImage, context: config.context, + executor: authorities.helperExecutor, privateRoot: config.paths.evidenceHelper, + timeoutMs: config.timeoutMs }; + const image = await resolvePreparedEvidenceHelperImage(helperInput, config.preparedEvidenceHelper); + const localHelper = createEvidenceExportHelper({ artifactManifestDigest: config.preparedEvidenceHelper.digest, + imageDigest: image.configDigest, imageReference: image.imageReference, resultHandle: config.preparedEvidenceHelper.handle }); + const operations = factories.evidence({ + ...common, artifactIdentityStore: authorities.artifactIdentityStore, + authorityStore: authorities.evidenceExportAuthorityStore, executor: authorities.executors.resource, + exportExecutor: authorities.executors.evidenceExport, helperArtifactContract: EVIDENCE_EXPORT_HELPER_CONTRACT, + helperArtifactManifestDigest: config.preparedEvidenceHelper.digest, localHelper + }); + return request.operation === "recover_operation" + ? operations.recover(request, config.evidenceDestination) + : operations.execute(request, config.evidenceDestination); + } if (!config.helperArtifact || !authorities.helperArtifactResolver) { throw new Error("Target evidence helper is not configured"); } diff --git a/src/cli/targetDefaultHandlers.test.ts b/src/cli/targetDefaultHandlers.test.ts index b181b868..56be6080 100644 --- a/src/cli/targetDefaultHandlers.test.ts +++ b/src/cli/targetDefaultHandlers.test.ts @@ -138,6 +138,7 @@ const fixture = () => { }), handoffResolver: Object.freeze({ resolve: vi.fn() }), helperArtifactResolver: Object.freeze({ resolve: resolveHelper }), + helperExecutor: executor, journals: Object.freeze({ resolve: resolveJournal }), secretAuthorityStore: Object.freeze({ marker: "secret-store" }), secretResolver: Object.freeze({ resolve: vi.fn() }), diff --git a/src/cli/targetDefaultHandlers.ts b/src/cli/targetDefaultHandlers.ts index d17dec00..816cd310 100644 --- a/src/cli/targetDefaultHandlers.ts +++ b/src/cli/targetDefaultHandlers.ts @@ -5,8 +5,8 @@ import type { } from "../target/contracts.js"; import type { TargetTopologyAttestationResult } from "../target/topologyAttestation.js"; import type { - TargetPublicArtifactSnapshot, - TargetPublicArtifactSnapshotRequest + TargetPublicArtifactSnapshotRequest, + TargetPublicArtifactSnapshotResult } from "../target/publicArtifactSnapshot.js"; import type { TargetTopologyActivationResult } from "../target/topologyActivation.js"; @@ -77,7 +77,7 @@ export const attestTargetDefaultTopology = async ( export const snapshotTargetDefaultPublicArtifact = async ( config: TargetDefaultConfig, request: TargetPublicArtifactSnapshotRequest -): Promise => { +): Promise => { const session = await initializeTargetDefaultAuthoritySession(config); try { return await createDockerPublicArtifactSnapshotReader({ diff --git a/src/cli/targetWorldClockCrossProcess.test-helper.ts b/src/cli/targetWorldClockCrossProcess.test-helper.ts index 26badc69..4433e837 100644 --- a/src/cli/targetWorldClockCrossProcess.test-helper.ts +++ b/src/cli/targetWorldClockCrossProcess.test-helper.ts @@ -48,7 +48,10 @@ const inspection = (spec: DockerWorldServiceSpec): Record => ({ NetworkAttachmentCount: 1, NetworkAttachmentId: "b".repeat(64), NetworkAttachmentName: after(spec.createArgs, "--network"), NetworkMode: after(spec.createArgs, "--network"), PidMode: "", PortBindingCount: 0, Privileged: false, PublishAllPorts: false, ReadonlyRootfs: true, RestartMaximumRetryCount: 0, RestartPolicyName: "no", - SecurityOpt: ["no-new-privileges=true"], Status: "running", Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + SecurityOpt: ["no-new-privileges=true"], Status: "running", Tmpfs: { + "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", + "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" + }, UTSMode: "", UsernsMode: "", VolumesFromCount: 0, }); diff --git a/src/cli/upCommand.ts b/src/cli/upCommand.ts index 97e30bd6..6da16ddd 100644 --- a/src/cli/upCommand.ts +++ b/src/cli/upCommand.ts @@ -4,10 +4,15 @@ import type { Command } from "commander"; import { createDeploymentInstanceDigest, + admitLifecyclePlan, + LIFECYCLE_UP_EXTRA_LABELS, + recordLifecycleUpReservation, + recordLifecycleUpStart, readDeploymentRecord, recordLifecycleOutcomeEvidence, type UpReceipt } from "../deployment/index.js"; +import type { UpLifecycleRecovery } from "../deployment/upLifecycleRecoveryState.js"; import { SpawnfileError } from "../shared/index.js"; import { requireMachineLifecycle, runMachineLifecycle } from "./lifecycleMachine.js"; @@ -114,11 +119,16 @@ export const registerUpCommand = ( "`--world-bindings` is only supported for project-mode deployments" ); } + if (options.lifecycleInvocation !== undefined) { + throw new SpawnfileError( + "validation_error", + "Machine lifecycle image up is not supported; use the project deployment contract" + ); + } if (options.json) { throw new SpawnfileError( "validation_error", - "`spawnfile up --json` is not yet supported for image-mode deployments " + - "(there is no source Spawnfile to derive compiled_schedule from)." + "Machine-readable lifecycle up is supported only for project deployments; use the project deployment contract" ); } await runImageUpCommand(upInput.ref, options, handlers, streams); @@ -163,14 +173,50 @@ export const registerUpCommand = ( lifecycleInvocation: options.lifecycleInvocation }); const render = async ( - capability?: Parameters[2] + capability?: Parameters[2], + lifecycleRecovery?: UpLifecycleRecovery, ): Promise => { const selectedTargetReceipt = options.selectedTargetReceipt === undefined ? undefined : await readSelectedTargetReceipt(options.selectedTargetReceipt); + const projectOptions = createUpProjectOptions({ ...options, selectedTargetReceipt }); + const lifecycleProjectOptions = exactInvocation && capability + ? { + ...projectOptions, + ...(lifecycleRecovery === undefined ? {} : { lifecycleRecovery }), + onDetachedReservation: async (reserved: { + containerName: string; + deploymentLabels: Readonly>; + dockerCommand: string; + dockerContext: string | null; + }) => recordLifecycleUpReservation(exactInvocation, { + container_name: reserved.containerName, + docker_command: reserved.dockerCommand, + docker_context: reserved.dockerContext, + label_authority: { + permitted_extra_labels: LIFECYCLE_UP_EXTRA_LABELS, + required: reserved.deploymentLabels, + }, + }, capability), + onDetachedStarted: async (started: { + containerId: string; + containerName: string; + deploymentLabels: Readonly>; + imageId: string; + }) => recordLifecycleUpStart(exactInvocation, { + container_id: started.containerId, + container_name: started.containerName, + image_id: started.imageId, + label_authority: { + permitted_extra_labels: LIFECYCLE_UP_EXTRA_LABELS, + required: started.deploymentLabels, + }, + }, capability) + } + : projectOptions; const result = await handlers.upProject( inputPath, - createUpProjectOptions({ ...options, selectedTargetReceipt }) + lifecycleProjectOptions ); const receipt: UpReceipt = await handlers.buildUpReceipt(inputPath, result); const bytes = JSON.stringify(receipt, null, 2); @@ -188,12 +234,13 @@ export const registerUpCommand = ( } return bytes; }; + if (exactInvocation) await admitLifecyclePlan(exactInvocation); const output = options.lifecycleInvocation === undefined ? await render() : await runMachineLifecycle( exactInvocation!, render, - () => reconcileUpLifecycle(inputPath, options, exactInvocation!) + (capability) => reconcileUpLifecycle(inputPath, options, exactInvocation!, capability) ); streams.stdout(output); return; diff --git a/src/cli/upLifecycleRecovery.test.ts b/src/cli/upLifecycleRecovery.test.ts new file mode 100644 index 00000000..055473cb --- /dev/null +++ b/src/cli/upLifecycleRecovery.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from "vitest"; + +const invocation = { + correlation: { project_path: "/project" }, id: `lci_${"u".repeat(16)}`, + operation: "up", request_policy: {}, version: "spawnfile.lifecycle-invocation.v1", +} as const; +const capability = { + epoch: "00000000-0000-4000-8000-000000000000", role: "recovery", +} as const; +const imageId = `sha256:${"a".repeat(64)}`; +const containerId = "c".repeat(64); +const imageLabels = { "org.opencontainers.image.source": "example" } as const; +const requiredLabels = { "dev.spawnfile.deployment": "default" } as const; +const labelAuthority = { + permitted_extra_labels: "image-config-labels", + required: requiredLabels, +} as const; +const reservation = { + container_name: "organization", + docker_command: "docker", + docker_context: "local", + invocation, + label_authority: labelAuthority, + version: "spawnfile.lifecycle-up-reservation.v1", +} as const; +const start = { + attempt: 0, + container_id: containerId, + container_name: "organization", + image_id: imageId, + invocation, + label_authority: labelAuthority, + version: "spawnfile.lifecycle-up-start.v1", +} as const; +const startState = { attempt: 0, start } as const; +const containerInspect = (labels: unknown = { ...imageLabels, ...requiredLabels }): string => [ + JSON.stringify(containerId), JSON.stringify("/organization"), JSON.stringify(imageId), JSON.stringify(labels), +].join("\n"); +const imageInspect = (labels: unknown = imageLabels): string => [ + JSON.stringify(imageId), JSON.stringify(labels), +].join("\n"); + +interface LoadInput { + activeStart?: typeof startState | null; + containerInspect?: Error | string; + imageInspect?: Error | string; + record?: unknown; + recordPresent?: boolean; + recordStartError?: Error; + reservation?: typeof reservation | null; +} + +const load = async (input: LoadInput = {}) => { + vi.resetModules(); + let activeStart = input.activeStart ?? null; + const execFile = vi.fn((_: string, args: string[], __: unknown, callback: Function) => { + const result = args.includes("container") && args.includes("inspect") + ? input.containerInspect : args.includes("image") && args.includes("inspect") + ? input.imageInspect : undefined; + if (result instanceof Error) return callback(result, { stderr: "", stdout: "" }); + if (typeof result === "string") return callback(null, { stderr: "", stdout: result }); + if (args.includes("rm")) return callback(null, { stderr: "", stdout: "" }); + return callback(new Error("No such object"), { stderr: "", stdout: "" }); + }); + const recordLifecycleUpStart = vi.fn(async (_: unknown, value: Omit) => { + if (input.recordStartError) throw input.recordStartError; + activeStart = { attempt: 0, start: { ...value, attempt: 0, invocation, version: start.version } } as typeof startState; + }); + const recordLifecycleUpCleanup = vi.fn(async () => { activeStart = null; }); + vi.doMock("node:child_process", () => ({ execFile })); + vi.doMock("node:fs/promises", () => ({ + lstat: vi.fn(async () => { + if (input.record !== undefined || input.recordPresent) return {}; + throw Object.assign(new Error("missing record"), { code: "ENOENT" }); + }), + })); + vi.doMock("../deployment/index.js", () => ({ + createDeploymentInstanceDigest: vi.fn(), + findLifecycleOutcomeEvidence: vi.fn().mockResolvedValue(null), + findLifecycleUpReservation: vi.fn().mockResolvedValue(input.reservation === undefined ? reservation : input.reservation), + findLifecycleUpStart: vi.fn().mockImplementation(async () => activeStart), + readDeploymentRecord: input.record === undefined + ? vi.fn().mockRejectedValue(new Error("record absent")) + : vi.fn().mockResolvedValue(input.record), + recordLifecycleUpCleanup, + recordLifecycleUpStart, + resolveDeploymentRecordPath: vi.fn(() => "/out/deployments/default.json"), + })); + vi.doMock("../filesystem/index.js", () => ({ + readUtf8File: vi.fn().mockResolvedValue(JSON.stringify({ compile_fingerprint: "fingerprint" })), + resolveProjectOutputDirectory: vi.fn(() => "/out"), + })); + const module = await import("./upLifecycleRecovery.js"); + return { execFile, recordLifecycleUpCleanup, recordLifecycleUpStart, reconcile: module.reconcileUpLifecycle }; +}; + +const reconcile = (run: Awaited>) => run.reconcile( + "/project", { context: "local", deployment: "default" }, invocation, capability, +); + +describe("up lifecycle recovery", () => { + it("retries after a fsynced reservation finds no exact container before Docker takes effect", async () => { + const run = await load(); + await expect(reconcile(run)).resolves.toMatchObject({ + recovery: { kind: "no_docker_mutation" }, status: "provably_not_applied", + }); + expect(run.recordLifecycleUpStart).not.toHaveBeenCalled(); + }); + + it("does not call a reservation-less recovery provably not applied", async () => { + const run = await load({ reservation: null }); + await expect(reconcile(run)).resolves.toEqual({ status: "resume_safe" }); + expect(run.execFile).not.toHaveBeenCalled(); + }); + + it("adopts an exact orphan after Docker started but before its durable start record", async () => { + const run = await load({ containerInspect: containerInspect(), imageInspect: imageInspect() }); + await expect(reconcile(run)).resolves.toMatchObject({ + recovery: { + containerId, + containerName: "organization", + deploymentLabels: requiredLabels, + imageId, + kind: "detached_container", + }, + status: "resume_safe", + }); + expect(run.recordLifecycleUpStart).toHaveBeenCalledWith(invocation, { + container_id: containerId, + container_name: "organization", + image_id: imageId, + label_authority: labelAuthority, + }, capability); + expect(run.execFile.mock.calls.map((call) => call[1])).toEqual([ + ["--context", "local", "container", "inspect", "--format", + "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", "organization"], + ["--context", "local", "image", "inspect", "--format", "{{json .Id}}\n{{json .Config.Labels}}", imageId], + ]); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); + + it("remains ambiguous if inspect fails before the start record can be published", async () => { + const run = await load({ containerInspect: new Error("transport reset") }); + await expect(reconcile(run)).resolves.toEqual({ + reason: "up_reserved_container_reconciliation_failed", status: "ambiguous", + }); + expect(run.recordLifecycleUpStart).not.toHaveBeenCalled(); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + }); + + it("leaves an exact orphan untouched if durable start-record publication fails", async () => { + const run = await load({ + containerInspect: containerInspect(), imageInspect: imageInspect(), recordStartError: new Error("record failed"), + }); + await expect(reconcile(run)).resolves.toEqual({ + reason: "up_reserved_container_reconciliation_failed", status: "ambiguous", + }); + expect(run.recordLifecycleUpStart).toHaveBeenCalledTimes(1); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + }); + + it("resumes a durably published start only after matching image and label authority", async () => { + const run = await load({ activeStart: startState, containerInspect: containerInspect(), imageInspect: imageInspect() }); + await expect(reconcile(run)).resolves.toMatchObject({ + recovery: { containerId, containerName: "organization", imageId, kind: "detached_container" }, + status: "resume_safe", + }); + expect(run.execFile.mock.calls.map((call) => call[1])).toEqual([ + ["--context", "local", "container", "inspect", "--format", + "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", containerId], + ["--context", "local", "image", "inspect", "--format", "{{json .Id}}\n{{json .Config.Labels}}", imageId], + ]); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); + + it("does not replace a recorded start that has disappeared", async () => { + const run = await load({ + activeStart: startState, + containerInspect: new Error("No such container"), + }); + await expect(reconcile(run)).resolves.toEqual({ + reason: "up_started_container_missing", status: "ambiguous", + }); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); + + it("refuses removal when the recorded image authority drifts", async () => { + const run = await load({ + activeStart: startState, + containerInspect: containerInspect({ ...imageLabels, ...requiredLabels }), + imageInspect: [JSON.stringify(`sha256:${"b".repeat(64)}`), JSON.stringify(imageLabels)].join("\n"), + }); + await expect(reconcile(run)).resolves.toEqual({ reason: "up_started_container_drifted", status: "ambiguous" }); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + }); + + it("keeps the container recorded when image re-verification cannot prove its authority", async () => { + const run = await load({ + activeStart: startState, + containerInspect: containerInspect(), + imageInspect: new Error("No such object"), + }); + await expect(reconcile(run)).resolves.toEqual({ + reason: "up_started_container_reconciliation_failed", status: "ambiguous", + }); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); + + it("refuses removal for unknown provider labels beyond explicit image-config labels", async () => { + const run = await load({ + activeStart: startState, + containerInspect: containerInspect({ ...imageLabels, ...requiredLabels, "provider.extra": "not-authorized" }), + imageInspect: imageInspect(), + }); + await expect(reconcile(run)).resolves.toEqual({ reason: "up_started_container_drifted", status: "ambiguous" }); + expect(run.execFile.mock.calls.some((call) => call[1].includes("rm"))).toBe(false); + }); + + it("replays an exact recorded deployment when receipt evidence was lost", async () => { + const record = { + auth_profile: null, + compile_fingerprint: "fingerprint", + name: "default", + organization_handoff: {}, + organization_handoff_handle: {}, + output_directory: "/out", + source: { kind: "project", root: "/project" }, + target: { kind: "context", name: "local" }, + units: [{ container_name: "organization" }], + }; + const run = await load({ activeStart: startState, record }); + await expect(reconcile(run)).resolves.toMatchObject({ + recovery: { kind: "deployment_record" }, status: "resume_safe", + }); + expect(run.execFile).not.toHaveBeenCalled(); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); + + it("does not clean an active start when a present deployment record is unreadable", async () => { + const run = await load({ activeStart: startState, recordPresent: true }); + await expect(reconcile(run)).resolves.toEqual({ + reason: "up_durable_evidence_absent_or_invalid", status: "ambiguous", + }); + expect(run.execFile).not.toHaveBeenCalled(); + expect(run.recordLifecycleUpCleanup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/upLifecycleRecovery.ts b/src/cli/upLifecycleRecovery.ts index ab181f69..de8d410d 100644 --- a/src/cli/upLifecycleRecovery.ts +++ b/src/cli/upLifecycleRecovery.ts @@ -1,83 +1,235 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { lstat } from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { - findLifecycleOutcomeEvidence, createDeploymentInstanceDigest, + findLifecycleOutcomeEvidence, + findLifecycleUpReservation, + findLifecycleUpStart, readDeploymentRecord, + recordLifecycleUpStart, resolveDeploymentRecordPath, + type LifecycleOwnerCapability, + type LifecycleUpReservation, + type LifecycleUpStartState, } from "../deployment/index.js"; -import { resolveProjectOutputDirectory } from "../filesystem/index.js"; +import { + deploymentRecordRecovery, + detachedContainerRecovery, + noDockerMutationRecovery, +} from "../deployment/upLifecycleRecoveryState.js"; +import { readUtf8File, resolveProjectOutputDirectory } from "../filesystem/index.js"; import type { CompileReport } from "../report/index.js"; import { DEFAULT_OUTPUT_DIRECTORY, REPORT_FILENAME } from "../shared/index.js"; -import { readUtf8File } from "../filesystem/index.js"; import type { LifecycleReconciliation } from "./lifecycleMachine.js"; import type { UpLifecycleOptions } from "./upLifecycleInvocation.js"; -const ambiguous = (reason: string): LifecycleReconciliation => ({ - reason, - status: "ambiguous", -}); +const execFile = promisify(execFileCallback); +const inspectFormat = "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}"; +const imageFormat = "{{json .Id}}\n{{json .Config.Labels}}"; + +const ambiguous = (reason: string): LifecycleReconciliation => ({ reason, status: "ambiguous" }); +const canonical = (value: unknown): string => value === null || typeof value !== "object" + ? JSON.stringify(value) + : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : `{${Object.keys(value as Record).sort().map((key) => + `${JSON.stringify(key)}:${canonical((value as Record)[key])}`).join(",")}}`; +const isRecord = (value: unknown): value is Record => value !== null + && typeof value === "object" && !Array.isArray(value) + && Object.values(value).every((entry) => typeof entry === "string"); const exactHandoff = ( record: Awaited>, options: UpLifecycleOptions, -): boolean => - record.organization_handoff !== undefined && - record.organization_handoff_handle !== undefined && - record.organization_handoff.network_attachment_handle === - options.networkAttachmentHandle && - record.organization_handoff.selected_target_receipt_digest === - options.selectedTargetReceiptDigest; +): boolean => record.organization_handoff !== undefined + && record.organization_handoff_handle !== undefined + && record.organization_handoff.network_attachment_handle === options.networkAttachmentHandle + && record.organization_handoff.selected_target_receipt_digest === options.selectedTargetReceiptDigest; -export const reconcileUpLifecycle = async ( - inputPath: string, +const exactNoSuchContainer = (error: unknown): boolean => + /(?:No such container|No such object)/u.test(error instanceof Error ? error.message : String(error)); +const missingDeploymentRecord = async (recordPath: string): Promise => { + try { + await lstat(recordPath); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } +}; +const dockerArgs = (reservation: LifecycleUpReservation, args: string[]): string[] => [ + ...(reservation.docker_context ? ["--context", reservation.docker_context] : []), ...args, +]; +const reservationMatchesRequest = ( + reservation: LifecycleUpReservation, options: UpLifecycleOptions, - invocation: Parameters[0], -): Promise => { - const outputDirectory = resolveProjectOutputDirectory( - inputPath, - options.out, - DEFAULT_OUTPUT_DIRECTORY, - ); - const deployment = options.deployment; - if (!deployment) return ambiguous("up_deployment_not_bound"); - const recordPath = resolveDeploymentRecordPath(outputDirectory, deployment); +): boolean => reservation.docker_context === (options.context ?? null) + && reservation.docker_command === (options.dockerCommand ?? "docker"); + +interface ObservedContainer { + readonly container_id: string; + readonly container_name: string; + readonly image_id: string; + readonly labels: Record; +} +const inspectContainer = async ( + reservation: LifecycleUpReservation, + reference: string, +): Promise => { try { - const record = await readDeploymentRecord(recordPath); - const report = JSON.parse( - await readUtf8File(path.join(outputDirectory, REPORT_FILENAME)), - ) as CompileReport; - const requestedRoot = path.resolve(inputPath); - if ( - record.name !== deployment || - record.output_directory !== outputDirectory || - record.source.kind !== "project" || - path.resolve(record.source.root) !== requestedRoot || - record.compile_fingerprint !== report.compile_fingerprint || - record.auth_profile !== (options.authProfile ?? null) || - (options.context !== undefined && - (record.target.kind !== "context" || - record.target.name !== options.context)) || - (options.name !== undefined && - record.units[0]?.container_name !== options.name) || - !exactHandoff(record, options) - ) - return ambiguous("up_durable_evidence_drifted"); + const result = await execFile(reservation.docker_command, dockerArgs(reservation, [ + "container", "inspect", "--format", inspectFormat, reference, + ]), { timeout: 10_000 }); + const [id, name, image, labels, ...extra] = result.stdout.trim().split("\n"); + const containerId = id ? JSON.parse(id) : undefined; + const containerName = name ? JSON.parse(name) : undefined; + const imageId = image ? JSON.parse(image) : undefined; + const parsedLabels = labels ? JSON.parse(labels) : undefined; + if (extra.length || typeof containerId !== "string" || !/^[a-f0-9]{64}$/u.test(containerId) + || typeof containerName !== "string" || !containerName.startsWith("/") || containerName.length < 2 + || typeof imageId !== "string" + || !/^sha256:[a-f0-9]{64}$/u.test(imageId) || !isRecord(parsedLabels)) throw new Error("invalid inspect"); + return { container_id: containerId, container_name: containerName.slice(1), image_id: imageId, labels: parsedLabels }; + } catch (error) { + if (exactNoSuchContainer(error)) return null; + throw error; + } +}; - const evidence = await findLifecycleOutcomeEvidence(invocation); - if (!evidence) return ambiguous("up_receipt_evidence_absent"); - if ( - evidence.deployment_instance_digest !== - createDeploymentInstanceDigest(record) - ) - return ambiguous("up_deployment_instance_changed"); +const verifyImageLabels = async ( + reservation: LifecycleUpReservation, + observed: ObservedContainer, +): Promise => { + const result = await execFile(reservation.docker_command, dockerArgs(reservation, [ + "image", "inspect", "--format", imageFormat, observed.image_id, + ]), { timeout: 10_000 }); + const [id, labels, ...extra] = result.stdout.trim().split("\n"); + const imageId = id ? JSON.parse(id) : undefined; + const parsedLabels = labels ? JSON.parse(labels) : {}; + const imageLabels = parsedLabels === null ? {} : parsedLabels; + if (extra.length || imageId !== observed.image_id || !isRecord(imageLabels)) return false; + const required = reservation.label_authority.required; + const expected = { ...imageLabels, ...required }; + return canonical(observed.labels) === canonical(expected); +}; + +const verifyObserved = async ( + reservation: LifecycleUpReservation, + observed: ObservedContainer | null, + expectedId?: string, + expectedImageId?: string, +): Promise => observed !== null + && observed.container_name === reservation.container_name + && (expectedId === undefined || observed.container_id === expectedId) + && (expectedImageId === undefined || observed.image_id === expectedImageId) + && await verifyImageLabels(reservation, observed); + +const recoverRecordedDetachedContainer = async ( + reservation: LifecycleUpReservation, + state: LifecycleUpStartState, +): Promise => { + const start = state.start; + if (start.container_name !== reservation.container_name + || canonical(start.label_authority) !== canonical(reservation.label_authority)) { + return ambiguous("up_started_container_authority_drifted"); + } + try { + const observed = await inspectContainer(reservation, start.container_id); + if (observed === null) return ambiguous("up_started_container_missing"); + if (!await verifyObserved(reservation, observed, start.container_id, start.image_id)) { + return ambiguous("up_started_container_drifted"); + } return { - outcomeBytes: evidence.completion.outcome_bytes, - status: "completed", + recovery: detachedContainerRecovery({ + containerId: observed.container_id, + containerName: observed.container_name, + deploymentLabels: reservation.label_authority.required, + imageId: observed.image_id, + }), + status: "resume_safe", }; } catch { - return ambiguous("up_durable_evidence_absent_or_invalid"); + return ambiguous("up_started_container_reconciliation_failed"); + } +}; + +const reconcileUnrecordedStart = async ( + invocation: Parameters[0], + options: UpLifecycleOptions, + capability: LifecycleOwnerCapability, +): Promise => { + let reservation: LifecycleUpReservation | null; + try { reservation = await findLifecycleUpReservation(invocation); } catch { return ambiguous("up_reservation_invalid"); } + // A current implementation performs no Docker effect before publishing this + // fsynced reservation, so its absence is restart-safe but never called not-applied. + if (reservation === null) return { status: "resume_safe" }; + if (!reservationMatchesRequest(reservation, options)) return ambiguous("up_reservation_request_drifted"); + try { + const observed = await inspectContainer(reservation, reservation.container_name); + if (observed === null) { + return { recovery: noDockerMutationRecovery(), status: "provably_not_applied" }; + } + if (!await verifyObserved(reservation, observed)) return ambiguous("up_reserved_container_drifted"); + await recordLifecycleUpStart(invocation, { + container_id: observed.container_id, + container_name: observed.container_name, + image_id: observed.image_id, + label_authority: reservation.label_authority, + }, capability); + return { + recovery: detachedContainerRecovery({ + containerId: observed.container_id, + containerName: observed.container_name, + deploymentLabels: reservation.label_authority.required, + imageId: observed.image_id, + }), + status: "resume_safe", + }; + } catch { return ambiguous("up_reserved_container_reconciliation_failed"); } +}; + +export const reconcileUpLifecycle = async ( + inputPath: string, + options: UpLifecycleOptions, + invocation: Parameters[0], + capability: LifecycleOwnerCapability, +): Promise => { + const outputDirectory = resolveProjectOutputDirectory(inputPath, options.out, DEFAULT_OUTPUT_DIRECTORY); + if (!options.deployment) return ambiguous("up_deployment_not_bound"); + const recordPath = resolveDeploymentRecordPath(outputDirectory, options.deployment); + if (!await missingDeploymentRecord(recordPath)) { + let record: Awaited>; + try { record = await readDeploymentRecord(recordPath); } catch { + return ambiguous("up_durable_evidence_absent_or_invalid"); + } + try { + const report = JSON.parse(await readUtf8File(path.join(outputDirectory, REPORT_FILENAME))) as CompileReport; + if (record.name !== options.deployment || record.output_directory !== outputDirectory + || record.source.kind !== "project" || path.resolve(record.source.root) !== path.resolve(inputPath) + || record.compile_fingerprint !== report.compile_fingerprint + || record.auth_profile !== (options.authProfile ?? null) + || (options.context !== undefined && (record.target.kind !== "context" || record.target.name !== options.context)) + || (options.name !== undefined && record.units[0]?.container_name !== options.name) + || !exactHandoff(record, options)) return ambiguous("up_durable_evidence_drifted"); + const evidence = await findLifecycleOutcomeEvidence(invocation); + if (!evidence) return { recovery: deploymentRecordRecovery(), status: "resume_safe" }; + return evidence.deployment_instance_digest === createDeploymentInstanceDigest(record) + ? { outcomeBytes: evidence.completion.outcome_bytes, status: "completed" } + : ambiguous("up_deployment_instance_changed"); + } catch { return ambiguous("up_durable_evidence_absent_or_invalid"); } + } + let started: LifecycleUpStartState | null; + try { started = await findLifecycleUpStart(invocation); } catch { + return ambiguous("up_start_record_unreadable"); + } + if (started) { + const reservation = await findLifecycleUpReservation(invocation).catch(() => null); + return reservation === null || !reservationMatchesRequest(reservation, options) + ? ambiguous("up_start_reservation_unavailable") + : recoverRecordedDetachedContainer(reservation, started); } + return reconcileUnrecordedStart(invocation, options, capability); }; diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index 122b4ca9..e0ced10b 100644 --- a/src/compiler/AGENTS.md +++ b/src/compiler/AGENTS.md @@ -48,10 +48,13 @@ src/compiler/ ├── moltnetArtifactPaths.ts # Moltnet artifact path, port, and volume-name helpers ├── moltnetArtifactTypes.ts # Moltnet artifact data contracts shared by compiler modules ├── moltnetRoomPolicyCompatibility.ts # Duplicate Moltnet network/room compatibility checks +├── organizationIdentity.ts # Public canonical organization identity surface +├── organizationIdentityGraph.ts # Internal root-team graph/path validation primitives +├── organizationExternalParticipants.ts # Nonempty participant B31 auth and intent lowering ├── moltnetBinaries.ts # Strict stamped and authority-pinned Moltnet binary staging ├── moltnetReleaseDownload.ts # Bounded exact-digest download for the pinned published release ├── moltnetReleaseAuthority.ts # Parser for the checked-in version/revision/asset digest trust root -├── daimonTelemetryArtifacts.ts # Run-scoped durable volume per pi/daimon agent for its daimon turn/wake causal telemetry directory +├── daimonTelemetryArtifacts.ts # Run-scoped durable volume for legacy generated-Pi telemetry ├── containerPackageOverrides.ts # Local runtime install package npm-pack staging for the container build context ├── upReceipt.ts # `spawnfile.up-receipt.v1` builder: compiled_schedule extraction + deployment-record readback ├── view/ # Pure compiler view models/renderers for `spawnfile view` @@ -100,23 +103,15 @@ src/compiler/ recomputing pi-internal engine resolution itself. This is the disclosure ground truth for a `scripted` (or any non-default) pi engine, so a scripted run is visibly scripted rather than an invisible test-only branch. -- `daimonTelemetryArtifacts.ts`'s `createDaimonTelemetryArtifacts` (Piece 4b, Decision 21) - registers one run-scoped durable volume per pi/daimon agent, mounted onto - `/runtime/agents//telemetry` (the parent of `causal.jsonl` — - `src/runtime/pi/appCoreSource.ts`'s `runtimeHomePath`/`instanceRoot`), mirroring exactly - how `moltnetArtifacts.ts` mounts the Moltnet causal directory: same - `createPersistentVolumeName(plan.root, id, undefined, runId)` run-scoping, same - `ContainerPersistentMountReport` shape, merged into `containerArtifacts.ts`'s - `persistent_mounts` alongside memory/moltnet mounts. It covers both the literal `pi` - runtime name and its `daimon` alias (`src/runtime/pi/adapter.ts`'s - `daimonAdapter = { ...piAdapter, name: "daimon" }`) since both generate the exact same - app and both write telemetry the same way. Each covered agent's mount id is also stamped - onto its runtime instance's `runtime_instances[].telemetry_mount_ids` (node id -> mount - id, `report/types.ts`) so `src/deployment/artifactsExportPlan.ts`'s `planDaimonFiles` can - resolve the volume without recomputing any container path itself. Before Piece 4b, daimon - inner-truth telemetry was container-local (`docker cp` only), which is WHY `spawnfile - artifacts export` had to run before `spawnfile down` removed the container; this closes - that gap for daimon, matching mneme/moltnet-causal (da12744). +- `daimonTelemetryArtifacts.ts` retains the legacy generated-Pi telemetry mount + layout. The Phase-A public `runtime: daimon` host has no Spawnfile telemetry + mount or Pi implementation path; add its public activity integration only in + a later Daimon control-plane phase. +- `runtime: daimon` lowers one strict public organization-host config, not a + generated Pi app. Schedules, MCP declarations, and every surface except a + compiler-owned Moltnet public-wake attachment fail closed. `runtime: pi` + remains the only generated Pi path and owns its legacy engine/auth/MCP/ + scheduler behavior. - Standard compiles use the published Daimon, Mneme, and authority-pinned Moltnet releases. Local package directories are accepted only through the explicit `CompileProjectOptions.runtimePackageOverrides` test/development diff --git a/src/compiler/buildCompilePlan.test.ts b/src/compiler/buildCompilePlan.test.ts index 2ad86923..cd8ffc95 100644 --- a/src/compiler/buildCompilePlan.test.ts +++ b/src/compiler/buildCompilePlan.test.ts @@ -106,7 +106,7 @@ describe("buildCompilePlan", () => { " field:", " wake: mentions", " - id: blue", - " runtime: daimon", + " runtime: pi", " workspace:", " docs:", " system: ./characters/blue.md", @@ -150,7 +150,7 @@ describe("buildCompilePlan", () => { expect(repeatedPlan).toEqual(plan); expect(plan.nodes).toHaveLength(4); expect(plan.runtimes.openclaw.nodeIds).toHaveLength(2); - expect(plan.runtimes.daimon.nodeIds).toHaveLength(1); + expect(plan.runtimes.pi.nodeIds).toHaveLength(1); expect(red?.value).toMatchObject({ docs: [{ content: "# Red player\n", role: "system" }], env: { COLOR: "red", MATCH: "training" }, diff --git a/src/compiler/compileProject.test.ts b/src/compiler/compileProject.test.ts index 9bc10a27..052b3b69 100644 --- a/src/compiler/compileProject.test.ts +++ b/src/compiler/compileProject.test.ts @@ -898,7 +898,7 @@ describe("compileProject", () => { ); it( - "compiles a Spawnfile-owned Daimon org into one generated app", + "compiles the legacy generated-Pi org into one generated app", async () => { const previousCli = process.env.SPAWNFILE_MOLTNET_CLI; const previousReleaseDir = process.env.SPAWNFILE_MOLTNET_RELEASE_DIR; @@ -945,15 +945,15 @@ describe("compileProject", () => { "daimon-org", "self" ]); - expect(container?.runtimes_installed).toEqual(["daimon"]); + expect(container?.runtimes_installed).toEqual(["pi"]); expect(container?.runtime_instances).toEqual([ { - config_path: "/var/lib/spawnfile/instances/daimon/pi-app/pi/pi-app.json", + config_path: "/var/lib/spawnfile/instances/pi/pi-app/pi/pi-app.json", engine_by_node_id: { "agent:mapper": "pi", "agent:reviewer": "pi" }, - home_path: "/var/lib/spawnfile/instances/daimon/pi-app/home", + home_path: "/var/lib/spawnfile/instances/pi/pi-app/home", id: "pi-app", internal_port: 19690, model_auth_methods: { @@ -962,16 +962,16 @@ describe("compileProject", () => { model_secrets_required: ["SPAWNFILE_CLI_AUTH_JSON"], node_ids: ["agent:mapper", "agent:reviewer"], published_port: null, - runtime: "daimon", + runtime: "pi", telemetry_mount_ids: { "agent:mapper": "agent-mapper-daimon-telemetry", "agent:reviewer": "agent-reviewer-daimon-telemetry" }, - workspace_path: "/var/lib/spawnfile/instances/daimon/pi-app/workspace" + workspace_path: "/var/lib/spawnfile/instances/pi/pi-app/workspace" } ]); expect(container?.runtime_homes).toEqual([ - "/var/lib/spawnfile/instances/daimon/pi-app/home" + "/var/lib/spawnfile/instances/pi/pi-app/home" ]); expect(container?.moltnet?.node_plans).toEqual([ { @@ -1026,22 +1026,20 @@ describe("compileProject", () => { const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); expect(dockerfile).toContain("FROM node:24-bookworm-slim"); - expect(dockerfile).toContain( - "COPY --from=noopolis/spawnfile-runtime-daimon:0.1.2 /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon" - ); + expect(dockerfile).toContain("@earendil-works/pi-coding-agent@0.79.10"); expect(dockerfile).toContain("COPY container/rootfs/ /"); const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); expect(entrypoint).toContain("/usr/local/bin/moltnet &"); expect(entrypoint).toContain("/usr/local/bin/moltnet node"); expect(entrypoint).toContain( - "'node' '/opt/spawnfile/runtime-installs/daimon/app.mjs' '/var/lib/spawnfile/instances/daimon/pi-app/pi/pi-app.json'" + "'node' '/opt/spawnfile/runtime-installs/pi/app.mjs' '/var/lib/spawnfile/instances/pi/pi-app/pi/pi-app.json'" ); expect(entrypoint).toContain( - "prepare_volume_resource 'shared-lab' '/var/lib/spawnfile/instances/daimon/pi-app/workspace/agents/mapper/shared-lab'" + "prepare_volume_resource 'shared-lab' '/var/lib/spawnfile/instances/pi/pi-app/workspace/agents/mapper/shared-lab'" ); expect(entrypoint).toContain( - "prepare_volume_resource 'shared-lab' '/var/lib/spawnfile/instances/daimon/pi-app/workspace/agents/reviewer/shared-lab'" + "prepare_volume_resource 'shared-lab' '/var/lib/spawnfile/instances/pi/pi-app/workspace/agents/reviewer/shared-lab'" ); const appConfig = JSON.parse( @@ -1054,7 +1052,7 @@ describe("compileProject", () => { "lib", "spawnfile", "instances", - "daimon", + "pi", "pi-app", "pi", "pi-app.json" @@ -1079,7 +1077,7 @@ describe("compileProject", () => { "lib", "spawnfile", "instances", - "daimon", + "pi", "pi-app", "workspace", "agents", diff --git a/src/compiler/containerArtifacts.ts b/src/compiler/containerArtifacts.ts index 5fcdb4c4..b22bfa92 100644 --- a/src/compiler/containerArtifacts.ts +++ b/src/compiler/containerArtifacts.ts @@ -126,6 +126,7 @@ export const createContainerArtifacts = async ( const persistentMounts = [ ...memoryArtifacts.mounts, ...daimonTelemetryArtifacts.mounts, + ...runtimePlans.flatMap((runtimePlan) => runtimePlan.persistentMounts ?? []), ...((options.moltnet?.persistentMounts ?? []).map((mount) => ({ id: mount.id, mount_path: mount.mountPath, @@ -286,7 +287,17 @@ export const createContainerArtifacts = async ( ); const files: EmittedFile[] = [ - ...createRootfsFiles(runtimePlans), + ...createRootfsFiles( + runtimePlans, + persistentMounts.map((mount) => mount.mount_path), + options.moltnet + ? { + externalParticipantArtifacts: options.moltnet.externalParticipantArtifacts, + nodePlans: options.moltnet.nodePlans, + serverPlans: options.moltnet.serverPlans + } + : undefined + ), ...(options.moltnet?.files ?? []), ...(options.worldBindings ? [{ content: options.worldBindings.canonicalBytes, mode: 0o600, path: WORLD_BINDINGS_OUTPUT_FILE }] diff --git a/src/compiler/containerArtifactsPlans.test.ts b/src/compiler/containerArtifactsPlans.test.ts index d4e7bae8..b4129532 100644 --- a/src/compiler/containerArtifactsPlans.test.ts +++ b/src/compiler/containerArtifactsPlans.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { openClawAdapter } from "../runtime/openclaw/adapter.js"; +import { daimonAdapter } from "../runtime/daimon/adapter.js"; import { createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; +import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { CompilePlan, ResolvedAgentNode, ResolvedTeamNode } from "./types.js"; const createAgent = (): ResolvedAgentNode => ({ @@ -74,4 +76,57 @@ describe("runtime target plan source identity", () => { sourceIds: ["team:group"] })); }); + + it("keeps an empty Daimon workspace in the one organization target", async () => { + const node: ResolvedAgentNode = { + ...createAgent(), + runtime: { name: "daimon", options: { engine: "agy" } } + }; + const compiled = await daimonAdapter.compileAgent(node); + expect(compiled.files).toEqual([]); + + const priorRunId = process.env.NOOPOLIS_RUN_ID; + process.env.NOOPOLIS_RUN_ID = "run-that-must-not-scope-the-host-realm"; + let result: Awaited>; + try { + result = await createRuntimeTargetPlans({ + edges: [], + nodes: [], + root: "/tmp/Spawnfile", + runtimes: { daimon: { nodeIds: [] } } + }, [{ + emittedFiles: compiled.files, + id: "agent:assistant", + kind: "agent", + runtimeName: "daimon", + slug: "assistant", + value: node + }]); + } finally { + if (priorRunId === undefined) delete process.env.NOOPOLIS_RUN_ID; + else process.env.NOOPOLIS_RUN_ID = priorRunId; + } + + expect(result).toContainEqual(expect.objectContaining({ + engineByNodeId: { "agent:assistant": "agy" }, + id: "daimon-organization", + modelAuthMethods: {}, + modelSecretsRequired: [], + opaqueMountTargets: ["/var/lib/spawnfile/daimon/agy-unlock-secret"], + persistentMounts: [ + { + id: "daimon-agy-runtime-home-assistant", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/assistant", + reason: "Daimon AGY subscription runtime home for agent:assistant", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-runtime-home-assistant") + }, + { + id: "daimon-agy-subscription-realm", + mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "Daimon host AGY subscription realm", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-subscription-realm") + } + ] + })); + }); }); diff --git a/src/compiler/containerArtifactsPlans.ts b/src/compiler/containerArtifactsPlans.ts index 04dfd334..062292d2 100644 --- a/src/compiler/containerArtifactsPlans.ts +++ b/src/compiler/containerArtifactsPlans.ts @@ -21,6 +21,7 @@ import { resolveTargetPackages } from "./containerTargetPlanResolution.js"; import { listExecutionModelSecretNames } from "./modelEnv.js"; +import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { MoltnetArtifacts } from "./moltnetArtifacts.js"; import type { CompilePlan } from "./types.js"; import { findWorldBindingForNode, type ResolvedWorldBindings } from "./worldBindings.js"; @@ -66,8 +67,10 @@ export const createEnvVariableMap = ( for (const node of compiledNodes) { if (node.value.kind === "agent") { for (const secret of node.value.secrets) registerSecret(secret); - for (const secretName of listExecutionModelSecretNames(node.value.execution)) { - register(secretName, true, `Model provider auth for ${secretName}`, "model"); + if (node.runtimeName !== "daimon") { + for (const secretName of listExecutionModelSecretNames(node.value.execution)) { + register(secretName, true, `Model provider auth for ${secretName}`, "model"); + } } for (const secretName of listAgentSurfaceSecretNames(node.value.surfaces)) { register(secretName, true, "Bot token for declared agent surfaces", "surface"); @@ -124,7 +127,9 @@ export const createRuntimeTargetPlans = async ( const adapter = getRuntimeAdapter(runtimeName); const recipe = await createRuntimeInstallRecipe(runtimeName); const targetInputs = compiledNodes - .filter((node) => node.runtimeName === runtimeName && node.emittedFiles.length > 0) + .filter((node) => + node.runtimeName === runtimeName && (node.emittedFiles.length > 0 || runtimeName === "daimon") + ) .map((node): ContainerTargetInput => { const id = node.id ?? `${node.kind}:${node.slug}`; const worldBinding = node.kind === "agent" @@ -156,8 +161,15 @@ export const createRuntimeTargetPlans = async ( id: target.id, instancePaths, meta: adapter.container, - modelAuthMethods: resolveTargetModelAuthMethods(target, targetInputs), - modelSecretsRequired: resolveTargetModelSecrets(target, targetInputs), + modelAuthMethods: runtimeName === "daimon" ? {} : resolveTargetModelAuthMethods(target, targetInputs), + modelSecretsRequired: runtimeName === "daimon" ? [] : resolveTargetModelSecrets(target, targetInputs), + ...(target.opaqueMountTargets ? { opaqueMountTargets: [...target.opaqueMountTargets].sort() } : {}), + ...(target.persistentMounts ? { persistentMounts: target.persistentMounts.map((mount) => ({ + id: mount.id, + mount_path: mount.mountPath.replaceAll("", instancePaths.instanceRoot), + reason: mount.reason, + volume_name: createPersistentVolumeName(plan.root, mount.id) + })).sort((left, right) => left.id.localeCompare(right.id)) } : {}), port: adapter.container.port ? adapter.container.port + (index * portStride) : undefined, publishedPort: resolveTargetExposure(target, targetInputs) && adapter.container.port diff --git a/src/compiler/containerArtifactsRender.test.ts b/src/compiler/containerArtifactsRender.test.ts index 9c300d60..0e7d5b55 100644 --- a/src/compiler/containerArtifactsRender.test.ts +++ b/src/compiler/containerArtifactsRender.test.ts @@ -118,6 +118,36 @@ describe("renderDockerfile", () => { vi.doUnmock("../runtime/index.js"); }); + it("installs declared Daimon AGY realm packages on a prebuilt base image", async () => { + const { renderDockerfile } = await loadRenderModule({ + daimon: { + baseImage: "noopolis/spawnfile-runtime-daimon:test", + commands: [], + copyCommands: [], + runtimeName: "daimon", + runtimeRoot: "/opt/runtime/daimon" + } + }); + const plan = createRuntimePlan("daimon", { + meta: { + ...createRuntimePlan("daimon").meta, + systemDeps: ["bash", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux"] + } + }); + const dockerfile = await renderDockerfile([plan], { + persistentMountPaths: ["/var/lib/spawnfile/daimon/agy-subscription-realm"] + }); + expect(dockerfile).toContain("FROM noopolis/spawnfile-runtime-daimon:test"); + expect(dockerfile).toContain( + "apt-get install -y --no-install-recommends dbus-daemon gnome-keyring util-linux" + ); + expect(dockerfile).not.toContain( + "apt-get install -y --no-install-recommends bash ca-certificates curl" + ); + expect(dockerfile).not.toContain("secret-tool"); + expect(dockerfile).not.toContain("dbus-x11"); + }); + it("uses the highest node base image when a multi-runtime image includes node runtimes", async () => { const { renderDockerfile } = await loadRenderModule({ openclaw: { @@ -498,7 +528,7 @@ describe("renderDockerfile", () => { daimon: { commands: [], copyCommands: [ - "COPY --from=noopolis/spawnfile-runtime-daimon:0.1.2 /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon" + "COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon" ], runtimeName: "daimon", runtimeRoot: "/opt/spawnfile/runtime-installs/daimon" @@ -544,7 +574,7 @@ describe("renderDockerfile", () => { expect(dockerfile).toContain("FROM node:24-bookworm-slim"); expect(dockerfile).toContain( - "COPY --from=noopolis/spawnfile-runtime-daimon:0.1.2 /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon" + "COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon" ); expect(dockerfile).toContain( "COPY --from=noopolis/spawnfile-runtime-openclaw:2026.6.11 /opt/spawnfile/runtime-installs/openclaw /opt/spawnfile/runtime-installs/openclaw" diff --git a/src/compiler/containerArtifactsRender.ts b/src/compiler/containerArtifactsRender.ts index d3e1a55b..5484a920 100644 --- a/src/compiler/containerArtifactsRender.ts +++ b/src/compiler/containerArtifactsRender.ts @@ -19,6 +19,12 @@ import type { import type { EntrypointOptions } from "./containerEntrypointRender.js"; export { renderEntrypoint } from "./containerEntrypointRender.js"; export type { EntrypointOptions } from "./containerEntrypointRender.js"; +import { + DAIMON_RUNTIME_UID, + DAIMON_UID_ENTRYPOINT_PATH, + renderDaimonUidEntrypoint +} from "./containerDaimonUidEntrypointRender.js"; +import { createStateOwnershipCommand } from "./containerStateOwnershipRender.js"; import { MOLTNET_BIN_DIRECTORY, MOLTNET_BINARY_NAMES } from "./moltnetBinaries.js"; import { collectPackagesByManager, @@ -35,32 +41,15 @@ const CONTAINER_ROOTFS_ROOT = "container/rootfs"; const GATEWAY_PORT_PLACEHOLDER = ""; const WORKSPACE_PLACEHOLDER = ""; const RUNTIME_ROOT_PLACEHOLDER = ""; +const PREBUILT_FINAL_SYSTEM_DEPS_BY_RUNTIME: Readonly>> = { + daimon: new Set(["dbus-daemon", "gnome-keyring", "util-linux"]) +}; const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\"'\"'`)}'`; const extractNodeMajorVersion = (image: string): number => Number(image.match(/^node:(\d+)/)?.[1] ?? "0"); -const createStateOwnershipCommand = (persistentMountPaths: string[] = []): string => { - const mountPaths = [...new Set(persistentMountPaths)].sort(); - const mkdirPaths = [...new Set(["/var/lib/spawnfile", ...mountPaths])].sort(); - const markerCommands = mountPaths.map((mountPath) => - `touch ${shellQuote(path.posix.join(mountPath, ".spawnfile-volume-init"))}` - ); - const chownPaths = [ - ...new Set([ - "/var/lib/spawnfile", - ...mountPaths.filter((mountPath) => !mountPath.startsWith("/var/lib/spawnfile/")) - ]) - ].sort(); - - return [ - `mkdir -p ${mkdirPaths.map(shellQuote).join(" ")}`, - ...markerCommands, - `chown -R spawnfile:spawnfile ${chownPaths.map(shellQuote).join(" ")}` - ].join(" && "); -}; - const selectBaseImage = ( runtimePlans: RuntimeTargetPlan[], runtimeRecipes: RuntimeInstallRecipe[] @@ -136,6 +125,7 @@ export const renderDockerfile = async ( ); } const runtimeNames = [...new Set(runtimePlans.map((plan) => plan.runtimeName))]; + const hasDaimon = runtimeNames.includes("daimon"); const runtimeRecipes = await Promise.all( runtimeNames.map((runtimeName) => createRuntimeInstallRecipe(runtimeName, { packageOverrides: options.runtimePackageOverrides }) @@ -159,7 +149,11 @@ export const renderDockerfile = async ( const systemDeps = [ ...new Set([ ...runtimePlans.flatMap((plan) => - recipeByRuntimeName.get(plan.runtimeName)?.baseImage ? [] : plan.meta.systemDeps + recipeByRuntimeName.get(plan.runtimeName)?.baseImage + ? plan.meta.systemDeps.filter((dependency) => + PREBUILT_FINAL_SYSTEM_DEPS_BY_RUNTIME[plan.runtimeName]?.has(dependency) + ) + : plan.meta.systemDeps ), ...(needsGit ? ["git"] : []), ...(needsJsonEnvWriter ? ["python3"] : []) @@ -228,7 +222,9 @@ export const renderDockerfile = async ( } lines.push( - 'RUN if ! id -u spawnfile >/dev/null 2>&1; then useradd --create-home --home-dir /home/spawnfile --shell /bin/bash spawnfile; fi', + hasDaimon + ? `RUN if ! getent group spawnfile >/dev/null 2>&1; then groupadd --gid ${DAIMON_RUNTIME_UID} spawnfile; fi && if ! id -u spawnfile >/dev/null 2>&1; then useradd --uid ${DAIMON_RUNTIME_UID} --gid spawnfile --create-home --home-dir /home/spawnfile --shell /bin/bash spawnfile; fi` + : 'RUN if ! id -u spawnfile >/dev/null 2>&1; then useradd --create-home --home-dir /home/spawnfile --shell /bin/bash spawnfile; fi', "" ); @@ -236,7 +232,7 @@ export const renderDockerfile = async ( "COPY container/rootfs/ /", "COPY .env.example /opt/spawnfile/.env.example", 'COPY entrypoint.sh /opt/spawnfile/entrypoint.sh', - "RUN chmod +x /opt/spawnfile/entrypoint.sh" + `RUN chmod +x /opt/spawnfile/entrypoint.sh${hasDaimon ? ` ${DAIMON_UID_ENTRYPOINT_PATH}` : ""}` ); const postRootfsCommands = [ @@ -262,30 +258,43 @@ export const renderDockerfile = async ( ); } - lines.push(`RUN ${createStateOwnershipCommand(options.persistentMountPaths)}`); + lines.push(`RUN ${createStateOwnershipCommand( + runtimePlans, + options.persistentMountPaths, + options.moltnet + )}`); if (exposedPorts.length > 0) { lines.push(`EXPOSE ${exposedPorts.join(" ")}`); } - lines.push("USER spawnfile"); - lines.push('ENTRYPOINT ["/opt/spawnfile/entrypoint.sh"]'); + lines.push(hasDaimon ? "USER root" : "USER spawnfile"); + lines.push(hasDaimon + ? `ENTRYPOINT ["${DAIMON_UID_ENTRYPOINT_PATH}"]` + : 'ENTRYPOINT ["/opt/spawnfile/entrypoint.sh"]'); return `${lines.join("\n").trimEnd()}\n`; }; -export const createRootfsFiles = (runtimePlans: RuntimeTargetPlan[]): EmittedFile[] => - runtimePlans.flatMap((plan) => +export const createRootfsFiles = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[] = [], + moltnet?: EntrypointOptions["moltnet"] +): EmittedFile[] => { + const files = runtimePlans.flatMap((plan) => plan.targetFiles.map((file) => { + const renderedContent = file.content + .replaceAll("", plan.instancePaths.configPath) + .replaceAll(WORKSPACE_PLACEHOLDER, plan.instancePaths.workspacePath) + .replaceAll("", plan.instancePaths.instanceRoot ?? "") + .replaceAll(RUNTIME_ROOT_PLACEHOLDER, plan.runtimeRoot) + .replaceAll( + `"${GATEWAY_PORT_PLACEHOLDER}"`, + plan.port ? String(plan.port) : "0" + ) + .replaceAll(GATEWAY_PORT_PLACEHOLDER, plan.port ? String(plan.port) : ""); if (file.path === plan.meta.configFileName) { return { - content: file.content - .replaceAll(WORKSPACE_PLACEHOLDER, plan.instancePaths.workspacePath) - .replaceAll(RUNTIME_ROOT_PLACEHOLDER, plan.runtimeRoot) - .replaceAll( - `"${GATEWAY_PORT_PLACEHOLDER}"`, - plan.port ? String(plan.port) : "0" - ) - .replaceAll(GATEWAY_PORT_PLACEHOLDER, plan.port ? String(plan.port) : ""), + content: renderedContent, path: `${CONTAINER_ROOTFS_ROOT}${plan.instancePaths.configPath}` }; } @@ -293,7 +302,7 @@ export const createRootfsFiles = (runtimePlans: RuntimeTargetPlan[]): EmittedFil if (file.path.startsWith("runtime/")) { const relativeRuntimePath = file.path.slice("runtime/".length); return { - content: file.content, + content: renderedContent, path: `${CONTAINER_ROOTFS_ROOT}${path.posix.join( plan.runtimeRoot, relativeRuntimePath @@ -336,3 +345,11 @@ export const createRootfsFiles = (runtimePlans: RuntimeTargetPlan[]): EmittedFil ); }) ); + return runtimePlans.some((plan) => plan.runtimeName === "daimon") + ? [{ + content: renderDaimonUidEntrypoint(runtimePlans, persistentMountPaths, moltnet), + mode: 0o755, + path: `${CONTAINER_ROOTFS_ROOT}${DAIMON_UID_ENTRYPOINT_PATH}` + }, ...files] + : files; +}; diff --git a/src/compiler/containerArtifactsTypes.ts b/src/compiler/containerArtifactsTypes.ts index b11935f8..c7c0d6b5 100644 --- a/src/compiler/containerArtifactsTypes.ts +++ b/src/compiler/containerArtifactsTypes.ts @@ -1,6 +1,7 @@ /* v8 ignore file -- type-only module */ import type { DistributionReport } from "../distribution/index.js"; import type { ContainerReport } from "../report/index.js"; +import type { ContainerPersistentMountReport } from "../report/index.js"; import type { EmittedFile, RuntimeContainerConfigEnvBinding, @@ -40,6 +41,8 @@ export interface RuntimeTargetPlan { meta: RuntimeContainerMeta; modelAuthMethods: Record; modelSecretsRequired: string[]; + opaqueMountTargets?: string[]; + persistentMounts?: ContainerPersistentMountReport[]; port?: number; publishedPort?: number; /** diff --git a/src/compiler/containerDaimonUidEntrypointRender.test.ts b/src/compiler/containerDaimonUidEntrypointRender.test.ts new file mode 100644 index 00000000..2d469b68 --- /dev/null +++ b/src/compiler/containerDaimonUidEntrypointRender.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from "vitest"; +import { execFile as execFileCallback, spawnSync } from "node:child_process"; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import type { EntrypointOptions } from "./containerEntrypointRender.js"; +import { + DAIMON_AUTHORIZED_UID_ENV, + renderDaimonUidEntrypoint, + resolveDaimonUidEntrypointOwnershipPlan, + resolveDaimonUidEntrypointStateRoots +} from "./containerDaimonUidEntrypointRender.js"; + +const execFile = promisify(execFileCallback); +const authorizedUid = 501; + +const daimonPlan: RuntimeTargetPlan = { + engineByNodeId: { "agent:AGY": "agy", "agent:Codex One": "codex", "agent:Grok Two": "grok" }, + envFiles: [], id: "daimon-organization", + instancePaths: { + configPath: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json", + instanceRoot: "/var/lib/spawnfile/instances/daimon/daimon-organization", + workspacePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace" + }, + meta: { configFileName: "config.json", instancePaths: { configPathTemplate: "", workspacePathTemplate: "" }, standaloneBaseImage: "node:24", startCommand: [], systemDeps: [] }, + modelAuthMethods: {}, modelSecretsRequired: [], opaqueMountTargets: ["/var/lib/spawnfile/daimon/agy-unlock-secret"], + runtimeName: "daimon", runtimeRoot: "/opt/daimon", targetFiles: [] +}; + +const serverConfig = "/var/lib/spawnfile/moltnet/servers/local/Moltnet.json"; +const nodeConfig = "/var/lib/spawnfile/moltnet/nodes/agent.json"; +const causalState = "/var/lib/spawnfile/moltnet/servers/local/causal"; +const agyRealm = "/var/lib/spawnfile/daimon/agy-subscription-realm"; +const agyRuntimeHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy"; +const agyRealmMount = { + id: "daimon-agy-subscription-realm", + mount_path: agyRealm, + reason: "Daimon host AGY subscription realm", + volume_name: "spawnfile-test-agy-realm" +}; +const agyRuntimeHomeMount = { + id: "daimon-agy-runtime-home-agy", + mount_path: agyRuntimeHome, + reason: "Daimon AGY subscription runtime home for agent:agy", + volume_name: "spawnfile-test-agy-runtime-home" +}; +const moltnetPlans = { + nodePlans: [{ configPath: nodeConfig, networkId: "local" }], + serverPlans: [{ + baseUrl: "http://127.0.0.1:8787", + configPath: serverConfig, + id: "local", + mode: "managed" as const, + name: "Local", + networkId: "local", + port: 8787, + rooms: [], + secretPatches: [], + server: { + auth: { mode: "none" as const }, + listen: { bind: "127.0.0.1", port: 8787 }, + mode: "managed" as const, + store: { kind: "memory" as const } + }, + teamSource: "/fixture/Spawnfile" + }] +} satisfies NonNullable; + +describe("renderDaimonUidEntrypoint", () => { + it("reowns only compiler-authored state, skips opaque mounts, and drops every capability before the existing entrypoint", () => { + const rendered = renderDaimonUidEntrypoint( + [{ ...daimonPlan, persistentMounts: [agyRealmMount, agyRuntimeHomeMount] }], + [agyRealm, agyRuntimeHome, "/var/lib/spawnfile/daimon-state"], + moltnetPlans + ); + + expect(rendered).toContain(`uid="\${${DAIMON_AUTHORIZED_UID_ENV}:-1001}"`); + expect(rendered).toContain("runtime-homes/codex-one/.daimon-inbound/codex-auth"); + expect(rendered).toContain("runtime-homes/grok-two/.daimon-inbound/grok-auth"); + expect(rendered).toContain("/var/lib/spawnfile/daimon/agy-unlock-secret"); + expect(rendered).not.toContain("runtime-homes/agy/.daimon-inbound/agy-auth"); + expect(rendered).toContain("const opaquePaths = new Set("); + expect(rendered).toContain("constants.O_NOFOLLOW"); + expect(rendered).toContain("fs.fchownSync"); + expect(rendered).toContain(`const privateFiles = ["${nodeConfig}","${serverConfig}"];`); + expect(rendered).toContain(`const privateModeDirectories = ["${agyRealm}","${agyRuntimeHome}"];`); + expect(rendered).toContain("for (const target of privateDirectories)"); + expect(rendered).toContain("for (const target of privateModeDirectories)"); + expect(rendered).toContain("for (const target of privateFiles)"); + expect(rendered).toContain("const securePrivateDirectory = (fd) => {"); + expect(rendered).toContain("fs.fchownSync(fd, 0, 0);"); + expect(rendered).toContain("fs.fchmodSync(fd, 0o700)"); + expect(rendered).toContain("fs.fchownSync(fd, uid, uid);"); + expect(rendered).toContain("fail('unable to secure private directory')"); + expect(rendered).toContain("info.uid !== uid || info.gid !== uid || (info.mode & 0o777) !== 0o700"); + const temporaryRootOwnership = rendered.indexOf("fs.fchownSync(fd, 0, 0);"); + const privateModeRepair = rendered.indexOf("fs.fchmodSync(fd, 0o700);", temporaryRootOwnership); + const authorizedOwnership = rendered.indexOf("fs.fchownSync(fd, uid, uid);", privateModeRepair); + expect(temporaryRootOwnership).toBeGreaterThan(-1); + expect(privateModeRepair).toBeGreaterThan(temporaryRootOwnership); + expect(authorizedOwnership).toBeGreaterThan(privateModeRepair); + expect(rendered).toContain("mountOptionsFor(target).includes('ro')"); + expect(rendered).toContain('node - "$uid" "${state_roots[@]}"'); + expect(rendered).not.toContain("SPAWNFILE_DAIMON_WRITABLE_ROOTS"); + expect(rendered).toContain('if ! getent passwd "$uid"'); + expect(rendered).toContain('useradd -K UID_MIN=1 --no-create-home --no-log-init --uid "$uid"'); + expect(rendered).toContain("--clear-groups --reuid \"$uid\" --regid \"$gid\""); + expect(rendered).toContain("--inh-caps=-all --ambient-caps=-all --bounding-set=-all"); + expect(rendered).toContain('if [ "$EUID" -eq 0 ]'); + expect(rendered).toContain('CapEff:[[:space:]]*'); + expect(rendered).toContain('exec "$@"'); + expect(rendered).toContain( + 'runtime_command=(bash \'/opt/daimon/daimon-start.sh\' "$@")' + ); + expect(rendered).not.toContain('runtime_command=(daimon-runtime "$@")'); + expect(rendered).toContain('[ "$1" != auth ]'); + expect(rendered).toContain("/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json"); + expect(rendered).not.toMatch(/find -P|chown -R| cp /); + expect(rendered).not.toMatch(/chmod -R/); + }); + + it("repairs only exact private traversal ancestors, Moltnet configs, and writable leaves", () => { + expect(resolveDaimonUidEntrypointOwnershipPlan( + [{ ...daimonPlan, persistentMounts: [agyRealmMount, agyRuntimeHomeMount] }], + [agyRealm, agyRuntimeHome, causalState, "/external-state"], + moltnetPlans + )).toEqual({ + privateDirectories: [ + "/var/lib/spawnfile", + "/var/lib/spawnfile/daimon", + agyRealm, + "/var/lib/spawnfile/instances", + "/var/lib/spawnfile/instances/daimon", + "/var/lib/spawnfile/instances/daimon/daimon-organization", + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes", + agyRuntimeHome, + "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace", + "/var/lib/spawnfile/moltnet", + "/var/lib/spawnfile/moltnet/nodes", + "/var/lib/spawnfile/moltnet/servers", + "/var/lib/spawnfile/moltnet/servers/local", + causalState + ], + privateFiles: [nodeConfig, serverConfig], + privateModeDirectories: [agyRealm, agyRuntimeHome], + stateRoots: [ + "/external-state", + agyRealm, + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes", + agyRuntimeHome, + "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace", + causalState + ] + }); + }); + + it("omits pruning when no Daimon opaque mount was declared", () => { + const rendered = renderDaimonUidEntrypoint([{ + ...daimonPlan, + engineByNodeId: undefined, + opaqueMountTargets: undefined, + instancePaths: { + ...daimonPlan.instancePaths, + homePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/home" + } + }]); + + expect(rendered).toContain("const opaquePaths = new Set([]);"); + }); + + it("uses only absolute roots when plan metadata or persistent input is incomplete", () => { + const rendered = renderDaimonUidEntrypoint([{ + ...daimonPlan, + instancePaths: { + ...daimonPlan.instancePaths, + instanceRoot: undefined, + workspacePath: "relative-workspace" + } + }], ["relative-state", "/persisted-state", "/persisted-state"]); + + expect(rendered).toContain("state_roots=('/persisted-state')"); + expect(rendered).not.toContain("runtime-homes/codex-one/.daimon-inbound/codex-auth"); + }); + + it("repairs a fresh volume and private Moltnet ancestors for authorized UID 501 across restart", async () => { + const instanceRoot = "/var/lib/spawnfile/instances/daimon/daimon-organization"; + const workspacePath = `${instanceRoot}/workspace`; + const runtimeHomesPath = `${instanceRoot}/runtime-homes`; + const dockerDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-uid-image-")); + const tag = `spawnfile-daimon-uid-${Date.now().toString(36)}`; + const containerName = `${tag}-container`; + const volumeName = `${tag}-realm-volume`; + const runtimeHomeVolumeName = `${tag}-agy-runtime-home-volume`; + const plan: RuntimeTargetPlan = { + ...daimonPlan, + engineByNodeId: undefined, + instancePaths: { + configPath: `${instanceRoot}/daimon/config.json`, + instanceRoot, + workspacePath + }, + meta: { + ...daimonPlan.meta, + configFileName: "daimon/config.json", + startCommand: ["true"], + systemDeps: ["bash", "dbus-daemon", "util-linux"] + }, + persistentMounts: [ + { ...agyRealmMount, volume_name: volumeName }, + { ...agyRuntimeHomeMount, volume_name: runtimeHomeVolumeName } + ], + targetFiles: [{ content: "{}\n", path: "daimon/config.json" }] + }; + try { + vi.resetModules(); + vi.doMock("../runtime/index.js", () => ({ + createRuntimeInstallRecipe: vi.fn(async () => ({ + baseImage: "node:24-bookworm-slim", + commands: [], + copyCommands: [], + runtimeName: "daimon", + runtimeRoot: "/opt/daimon" + })) + })); + const { createRootfsFiles, renderDockerfile } = await import("./containerArtifactsRender.js"); + const dockerfile = await renderDockerfile([plan], { + moltnet: moltnetPlans, + persistentMountPaths: [agyRealm, agyRuntimeHome, causalState] + }); + const stateRoots = resolveDaimonUidEntrypointStateRoots([plan]); + expect(stateRoots).toEqual([runtimeHomesPath, workspacePath]); + for (const stateRoot of stateRoots) { + expect(dockerfile).toContain(`install -d -o root -g root -m 700 '${stateRoot}'`); + } + expect(dockerfile).not.toContain("SPAWNFILE_DAIMON_WRITABLE_ROOTS"); + expect(dockerfile).not.toContain("/untrusted"); + + for (const file of createRootfsFiles([plan], [agyRealm, agyRuntimeHome, causalState], moltnetPlans)) { + const outputPath = path.join(dockerDirectory, file.path); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, file.content, "utf8"); + } + for (const configPath of [nodeConfig, serverConfig]) { + const outputPath = path.join(dockerDirectory, "container/rootfs", configPath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, "{}\n", { encoding: "utf8", mode: 0o600 }); + } + await writeFile(path.join(dockerDirectory, ".env.example"), "", "utf8"); + await writeFile( + path.join(dockerDirectory, "entrypoint.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `test \"$(id -u)\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}\"`, + "getent passwd \"$(id -u)\" >/dev/null", + "test \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" = 0000000000000000", + `test \"$(stat -c '%u:%a' '/var/lib/spawnfile')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet/servers/local')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '${serverConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, + `test \"$(stat -c '%u:%a' '${nodeConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, + `test \"$(stat -c '%u:%a' '${causalState}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '${agyRealm}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '${runtimeHomesPath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '${workspacePath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test "$(stat -c '%u:%a' '${agyRuntimeHome}')" = "\${${DAIMON_AUTHORIZED_UID_ENV}}:700"`, + "test \"$(stat -c '%u:%a' /untrusted/sentinel)\" = 0:600", + "dbus_root=$(mktemp -d /tmp/spawnfile-dbus.XXXXXX)", + "chmod 700 \"$dbus_root\"", + "dbus_address=unix:path=$dbus_root/bus", + "dbus-daemon --session --fork --nopidfile --address=\"$dbus_address\"", + "dbus-send --bus=\"$dbus_address\" --dest=org.freedesktop.DBus --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null", + `count_file='${agyRealm}/starts'`, + "count=$(cat \"$count_file\" 2>/dev/null || printf 0)", + "printf %s $((count + 1)) > \"$count_file\"", + `token_marker='${agyRuntimeHome}/subscription-state'`, + "if [ \"$count\" = 0 ]; then printf enrolled > \"$token_marker\"; else test \"$(cat \"$token_marker\")\" = enrolled; fi", + `printf 'entrypoint uid=%s caps=%s realm=%s start=%s\\n' \"$(id -u)\" \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" \"$(stat -c '%u:%a' '${agyRealm}')\" \"$count\"` + ].join("\n") + "\n", + "utf8" + ); + await writeFile( + path.join(dockerDirectory, "Dockerfile"), + `${dockerfile}\nRUN install -d -o root -g root -m 755 /untrusted && install -o root -g root -m 600 /dev/null /untrusted/sentinel\n`, + "utf8" + ); + await execFile("docker", ["build", "--pull=false", "--tag", tag, "."], { + cwd: dockerDirectory, + timeout: 30_000 + }); + await execFile("docker", [ + "create", "--name", containerName, + "--cap-drop=ALL", "--cap-add=CHOWN", "--cap-add=SETUID", "--cap-add=SETGID", "--cap-add=DAC_READ_SEARCH", + "--security-opt=no-new-privileges:true", + "--env", `${DAIMON_AUTHORIZED_UID_ENV}=${authorizedUid}`, + "--env", "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/untrusted", + "--mount", `type=volume,source=${volumeName},target=${agyRealm}`, + "--mount", `type=volume,source=${runtimeHomeVolumeName},target=${agyRuntimeHome}`, + tag + ]); + const initial = await execFile("docker", ["start", "-a", containerName]); + const restarted = await execFile("docker", ["start", "-a", containerName]); + expect(initial.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=0`); + expect(restarted.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=1`); + } finally { + vi.doUnmock("../runtime/index.js"); + vi.resetModules(); + await execFile("docker", ["rm", "--force", containerName]).catch(() => undefined); + await execFile("docker", ["volume", "rm", "--force", volumeName]).catch(() => undefined); + await execFile("docker", ["volume", "rm", "--force", runtimeHomeVolumeName]).catch(() => undefined); + await execFile("docker", ["image", "rm", "--force", tag]).catch(() => undefined); + await rm(dockerDirectory, { force: true, recursive: true }); + } + }, 60_000); + + it("rejects an ancestor symlink and ignores run-env roots before a restart can reach an external entrypoint", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-root-link-")); + const externalRoot = path.join(directory, "opt", "spawnfile", "root"); + const protectedEntrypoint = path.join(externalRoot, "entrypoint.sh"); + const hostileParent = path.join(directory, "compiled"); + const instanceRoot = path.join(hostileParent, "instance"); + const runEnvironment = path.join(directory, "run.env"); + const wrapper = path.join(directory, "daimon-uid-entrypoint.sh"); + try { + await mkdir(externalRoot, { recursive: true }); + await writeFile(protectedEntrypoint, "trusted root entrypoint\n", "utf8"); + await symlink(path.join(directory, "opt", "spawnfile", "root"), hostileParent); + await writeFile(runEnvironment, "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/opt/spawnfile/root\n", "utf8"); + const rendered = renderDaimonUidEntrypoint([{ + ...daimonPlan, + instancePaths: { + ...daimonPlan.instancePaths, + instanceRoot, + workspacePath: path.join(instanceRoot, "workspace") + } + }]); + await writeFile(wrapper, rendered, "utf8"); + for (const _restart of [0, 1]) { + const result = spawnSync("bash", ["-c", 'set -a; . "$1"; set +a; exec bash "$2"', "bash", runEnvironment, wrapper], { + env: process.env + }); + expect(result.status).not.toBe(0); + expect(Buffer.from(result.stderr).toString("utf8")).toContain("symbolic-link"); + } + expect(await readFile(protectedEntrypoint, "utf8")).toBe("trusted root entrypoint\n"); + expect((await lstat(externalRoot)).isDirectory()).toBe(true); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/compiler/containerDaimonUidEntrypointRender.ts b/src/compiler/containerDaimonUidEntrypointRender.ts new file mode 100644 index 00000000..fcae8e9e --- /dev/null +++ b/src/compiler/containerDaimonUidEntrypointRender.ts @@ -0,0 +1,263 @@ +import path from "node:path"; + +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import type { EntrypointOptions } from "./containerEntrypointRender.js"; + +export const DAIMON_AUTHORIZED_UID_ENV = "SPAWNFILE_DAIMON_AUTHORIZED_UID"; +export const DAIMON_RUNTIME_UID = 1001; +export const DAIMON_UID_ENTRYPOINT_PATH = "/opt/spawnfile/daimon-uid-entrypoint.sh"; +export const DAIMON_RUNTIME_HOMES_DIRECTORY = "runtime-homes"; + +const SPAWNFILE_PRIVATE_STATE_ROOT = "/var/lib/spawnfile"; +const DAIMON_AGY_SUBSCRIPTION_REALM_MOUNT_ID = "daimon-agy-subscription-realm"; +const DAIMON_AGY_RUNTIME_HOME_MOUNT_ID_PREFIX = "daimon-agy-runtime-home-"; + +const quote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`; + +const nodeSlug = (nodeId: string): string => + nodeId.replace(/^agent:/u, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +const opaqueMountTargets = (runtimePlans: RuntimeTargetPlan[]): string[] => + [...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .flatMap((plan) => [ + ...(plan.opaqueMountTargets ?? []), + ...Object.entries(plan.engineByNodeId ?? {}) + .filter(([, engine]) => engine === "codex" || engine === "grok") + .map(([nodeId, engine]) => path.posix.join( + plan.instancePaths.instanceRoot ?? "", DAIMON_RUNTIME_HOMES_DIRECTORY, + nodeSlug(nodeId), ".daimon-inbound", `${engine}-auth` + )) + ]) + .filter((target) => target.startsWith("/")) + )].sort(); + +export const resolveDaimonUidEntrypointStateRoots = ( + runtimePlans: RuntimeTargetPlan[] +): string[] => [ + ...new Set([ + ...runtimePlans.flatMap((plan) => [ + plan.instancePaths.workspacePath, + ...(plan.instancePaths.homePath ? [plan.instancePaths.homePath] : []), + ...(plan.runtimeName === "daimon" && plan.instancePaths.instanceRoot + ? [path.posix.join(plan.instancePaths.instanceRoot, DAIMON_RUNTIME_HOMES_DIRECTORY)] + : []) + ]) + ]) +].filter((root) => root.startsWith("/")).sort(); + +const writableStateRoots = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[] +): string[] => [ + ...new Set([ + ...resolveDaimonUidEntrypointStateRoots(runtimePlans), + ...persistentMountPaths + ]) +].filter((root) => root.startsWith("/")).sort(); + +const privateDirectoriesThrough = (target: string): string[] => { + if ( + target !== SPAWNFILE_PRIVATE_STATE_ROOT && + !target.startsWith(`${SPAWNFILE_PRIVATE_STATE_ROOT}/`) + ) return []; + const relative = path.posix.relative(SPAWNFILE_PRIVATE_STATE_ROOT, target); + const segments = relative === "" ? [] : relative.split("/"); + return [ + SPAWNFILE_PRIVATE_STATE_ROOT, + ...segments.map((_, index) => + path.posix.join(SPAWNFILE_PRIVATE_STATE_ROOT, ...segments.slice(0, index + 1)) + ) + ]; +}; + +const privateModeDirectories = (runtimePlans: RuntimeTargetPlan[]): string[] => [ + ...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .flatMap((plan) => plan.persistentMounts ?? []) + .filter((mount) => mount.id === DAIMON_AGY_SUBSCRIPTION_REALM_MOUNT_ID + || mount.id.startsWith(DAIMON_AGY_RUNTIME_HOME_MOUNT_ID_PREFIX)) + .map((mount) => mount.mount_path) + .filter((target) => target.startsWith("/"))) +].sort(); + +const moltnetConfigPaths = ( + moltnet: EntrypointOptions["moltnet"] +): string[] => { + if (!moltnet) return []; + return [ + ...moltnet.serverPlans.flatMap((plan) => + plan.mode === "managed" && plan.configPath ? [plan.configPath] : [] + ), + ...moltnet.nodePlans.map((plan) => plan.configPath) + ].filter((configPath) => configPath.startsWith("/")).sort(); +}; + +export interface DaimonUidEntrypointOwnershipPlan { + privateDirectories: string[]; + privateFiles: string[]; + privateModeDirectories: string[]; + stateRoots: string[]; +} + +export const resolveDaimonUidEntrypointOwnershipPlan = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[] = [], + moltnet?: EntrypointOptions["moltnet"] +): DaimonUidEntrypointOwnershipPlan => { + const stateRoots = writableStateRoots(runtimePlans, persistentMountPaths); + const privateFiles = moltnetConfigPaths(moltnet); + const modeDirectories = privateModeDirectories(runtimePlans); + const privateDirectories = [ + ...new Set([ + ...stateRoots.flatMap(privateDirectoriesThrough), + ...privateFiles.flatMap((configPath) => + privateDirectoriesThrough(path.posix.dirname(configPath)) + ) + ]) + ].sort(); + return { + privateDirectories, + privateFiles, + privateModeDirectories: modeDirectories, + stateRoots + }; +}; + +const renderOwnershipProgram = ( + opaqueTargets: string[], + privateDirectories: string[], + privateFiles: string[], + privateModeDirectories: string[] +): string => [ + "const fs = require('node:fs');", + "const constants = fs.constants;", + "const uid = Number(process.argv[2]);", + "const roots = process.argv.slice(3);", + `const opaquePaths = new Set(${JSON.stringify(opaqueTargets)});`, + `const privateDirectories = ${JSON.stringify(privateDirectories)};`, + `const privateFiles = ${JSON.stringify(privateFiles)};`, + `const privateModeDirectories = ${JSON.stringify(privateModeDirectories)};`, + "const fail = (message) => { process.stderr.write(`Daimon ownership guard: ${message}\\n`); process.exit(1); };", + "if (!Number.isSafeInteger(uid) || uid < 1 || roots.length === 0) fail('invalid compiler-authored roots');", + "const decodeMountPath = (value) => value.replace(/\\\\([0-7]{3})/g, (_, octal) => String.fromCharCode(Number.parseInt(octal, 8)));", + "const mountOptionsFor = (target) => {", + " const matches = fs.readFileSync('/proc/self/mountinfo', 'utf8').trim().split('\\n').map((line) => line.split(' ')).filter((parts) => parts.length > 5).map((parts) => ({ point: decodeMountPath(parts[4]), options: parts[5].split(',') })).filter((mount) => target === mount.point || target.startsWith(`${mount.point}/`));", + " return matches.sort((left, right) => right.point.length - left.point.length)[0]?.options ?? [];", + "};", + "const openDirectoryPath = (target) => {", + " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored root');", + " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " try {", + " for (const segment of target.slice(1).split('/')) {", + " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored root');", + " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " fs.closeSync(fd); fd = next;", + " }", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory()) fail('root is not a directory');", + " if (mountOptionsFor(target).includes('ro')) fail('root is read-only');", + " return fd;", + " } catch (error) { try { fs.closeSync(fd); } catch {} fail('root has a symbolic-link or unavailable path component'); }", + "};", + "const openRegularFilePath = (target) => {", + " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored file');", + " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " try {", + " const segments = target.slice(1).split('/');", + " for (const [index, segment] of segments.entries()) {", + " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored file');", + " const isFile = index === segments.length - 1;", + " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_NOFOLLOW | (isFile ? 0 : constants.O_DIRECTORY));", + " fs.closeSync(fd); fd = next;", + " }", + " const info = fs.fstatSync(fd);", + " if (!info.isFile() || info.nlink !== 1) fail('compiler-authored file is not one regular file');", + " if (mountOptionsFor(target).includes('ro')) fail('compiler-authored file is read-only');", + " return fd;", + " } catch (error) { try { fs.closeSync(fd); } catch {} fail('file has a symbolic-link or unavailable path component'); }", + "};", + "const ownTree = (fd, device, currentPath) => {", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory() || info.dev !== device) return;", + " fs.fchownSync(fd, uid, uid);", + " for (const entry of fs.readdirSync(`/proc/self/fd/${fd}`, { withFileTypes: true })) {", + " const childPath = `${currentPath}/${entry.name}`;", + " if (opaquePaths.has(childPath)) continue;", + " let child;", + " try { child = fs.openSync(`/proc/self/fd/${fd}/${entry.name}`, constants.O_RDONLY | constants.O_NOFOLLOW | (entry.isDirectory() ? constants.O_DIRECTORY : 0)); } catch { fail('state tree contains a symbolic link or unavailable entry'); }", + " try { const childInfo = fs.fstatSync(child); if (childInfo.dev !== device || mountOptionsFor(childPath).includes('ro')) continue; if (childInfo.isDirectory()) ownTree(child, device, childPath); else if (childInfo.isFile() && childInfo.nlink === 1) fs.fchownSync(child, uid, uid); else if (!childInfo.isFile()) fail('state tree contains an unsupported entry'); } finally { fs.closeSync(child); }", + " }", + "};", + "const securePrivateDirectory = (fd) => {", + " try {", + " fs.fchownSync(fd, 0, 0);", + " fs.fchmodSync(fd, 0o700);", + " fs.fchownSync(fd, uid, uid);", + " } catch {", + " try { fs.fchownSync(fd, uid, uid); } catch {}", + " fail('unable to secure private directory');", + " }", + " const info = fs.fstatSync(fd);", + " if (info.uid !== uid || info.gid !== uid || (info.mode & 0o777) !== 0o700) fail('private directory ownership or mode did not apply');", + "};", + "for (const target of privateDirectories) { if (opaquePaths.has(target)) fail('compiler-authored directory overlaps opaque path'); const fd = openDirectoryPath(target); try { fs.fchownSync(fd, uid, uid); } finally { fs.closeSync(fd); } }", + "for (const target of privateModeDirectories) { if (opaquePaths.has(target)) fail('private directory overlaps opaque path'); const fd = openDirectoryPath(target); try { securePrivateDirectory(fd); } finally { fs.closeSync(fd); } }", + "for (const target of privateFiles) { if (opaquePaths.has(target)) fail('compiler-authored file overlaps opaque path'); const fd = openRegularFilePath(target); try { fs.fchownSync(fd, uid, uid); } finally { fs.closeSync(fd); } }", + "for (const root of roots) { if (opaquePaths.has(root)) fail('state root overlaps opaque path'); const fd = openDirectoryPath(root); try { ownTree(fd, fs.fstatSync(fd).dev, root); } finally { fs.closeSync(fd); } }" +].join("\n"); + +export const renderDaimonUidEntrypoint = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[] = [], + moltnet?: EntrypointOptions["moltnet"] +): string => { + const opaqueTargets = opaqueMountTargets(runtimePlans); + const daimonPlan = runtimePlans.find((plan) => plan.runtimeName === "daimon"); + const daimonConfigPath = daimonPlan?.instancePaths.configPath; + const daimonStartPath = daimonPlan + ? path.posix.join(daimonPlan.runtimeRoot, "daimon-start.sh") + : ""; + const ownershipPlan = resolveDaimonUidEntrypointOwnershipPlan( + runtimePlans, + persistentMountPaths, + moltnet + ); + return [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `uid="\${${DAIMON_AUTHORIZED_UID_ENV}:-${DAIMON_RUNTIME_UID}}"`, + 'case "$uid" in ""|*[!0-9]*|0) echo "Daimon authorized UID must be a nonzero integer" >&2; exit 1;; esac', + 'gid="$uid"', + `runtime_command=(${quote("/opt/spawnfile/entrypoint.sh")})`, + 'if [ "$#" -gt 0 ]; then', + ` if [ "$#" -ne 5 ] || [ "$1" != auth ] || [ "$2" != agy ] || [ "$3" != login ] || [ "$4" != --config ] || [ "$5" != ${quote(daimonConfigPath ?? "")} ]; then echo "Unsupported Daimon container command" >&2; exit 1; fi`, + ` runtime_command=(bash ${quote(daimonStartPath)} "$@")`, + "fi", + `state_roots=(${ownershipPlan.stateRoots.map(quote).join(" ")})`, + "node - \"$uid\" \"${state_roots[@]}\" <<'SPAWNFILE_DAIMON_OWNERSHIP'", + renderOwnershipProgram( + opaqueTargets, + ownershipPlan.privateDirectories, + ownershipPlan.privateFiles, + ownershipPlan.privateModeDirectories + ), + "SPAWNFILE_DAIMON_OWNERSHIP", + 'if ! getent passwd "$uid" >/dev/null; then', + ' runtime_identity="daimon-$uid"', + ' runtime_group="$(getent group "$gid" | cut -d: -f1 || true)"', + ' if [ -z "$runtime_group" ]; then groupadd -K GID_MIN=1 --gid "$gid" "$runtime_identity"; runtime_group="$runtime_identity"; fi', + ' 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', + "exec setpriv --clear-groups --reuid \"$uid\" --regid \"$gid\" --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '", + " if [ \"$EUID\" -eq 0 ]; then echo \"Daimon UID wrapper left root effective\" >&2; exit 1; fi", + " cap_eff=$(sed -n \"s/^CapEff:[[:space:]]*//p\" /proc/self/status)", + " if [ \"$cap_eff\" != \"0000000000000000\" ]; then echo \"Daimon UID wrapper retained effective capabilities\" >&2; exit 1; fi", + " exec \"$@\"", + `' bash "\${runtime_command[@]}"` + ].join("\n") + "\n"; +}; diff --git a/src/compiler/containerEntrypointRender.test.ts b/src/compiler/containerEntrypointRender.test.ts index 1bc7ac22..126a27d9 100644 --- a/src/compiler/containerEntrypointRender.test.ts +++ b/src/compiler/containerEntrypointRender.test.ts @@ -363,7 +363,7 @@ describe("renderEntrypoint MCP secret materialization", () => { }); describe("renderEntrypoint CLI credential materialization", () => { - it("requires CLI auth before materialization and startup, then unsets the runtime copy", () => { + it("requires Pi CLI auth before materialization and startup, then unsets the runtime copy", () => { const root = mkdtempSync(join(tmpdir(), "spawnfile-cli-auth-")); const homePath = join(root, "home"); const authPath = join(homePath, ".codex", "auth.json"); @@ -373,7 +373,7 @@ describe("renderEntrypoint CLI credential materialization", () => { meta: { ...runtimePlan().meta, startCommand: ["bash", "-c", `test -f ${authPath} && test -z "\${SPAWNFILE_CLI_AUTH_JSON:-}" && touch ${markerPath}`] }, instancePaths: { configPath: join(root, "config.json"), homePath, workspacePath: join(root, "workspace") }, modelAuthMethods: { openai: "codex" }, - runtimeName: "daimon" + runtimeName: "pi" })], [] ); diff --git a/src/compiler/containerEntrypointRender.ts b/src/compiler/containerEntrypointRender.ts index df6d9a01..22502062 100644 --- a/src/compiler/containerEntrypointRender.ts +++ b/src/compiler/containerEntrypointRender.ts @@ -103,6 +103,7 @@ const resolveStartCommand = (plan: RuntimeTargetPlan): string[] => token .replaceAll("", plan.instancePaths.configPath) .replaceAll("", plan.instancePaths.homePath ?? "") + .replaceAll("", plan.instancePaths.instanceRoot ?? "") .replaceAll("", plan.runtimeRoot) .replaceAll("", plan.instancePaths.workspacePath) .replaceAll("", plan.port ? String(plan.port) : "") @@ -110,10 +111,25 @@ const resolveStartCommand = (plan: RuntimeTargetPlan): string[] => .filter((token) => token.length > 0); const createRuntimeReadinessWait = (plan: RuntimeTargetPlan): string[] => { - if (!["openclaw", "pi"].includes(plan.runtimeName) || !plan.port) { - return []; + if (!plan.port) return []; + + if (plan.runtimeName === "daimon") { + return [ + "attempts=0", + `until curl -sf ${shellQuote(`http://127.0.0.1:${plan.port}/healthz`)} >/dev/null; do`, + " attempts=$((attempts + 1))", + ' if [ "$attempts" -ge 180 ]; then', + ` echo ${shellQuote(`Timed out waiting for daimon on port ${plan.port}`)} >&2`, + " exit 1", + " fi", + " sleep 1", + "done", + "" + ]; } + if (!["openclaw", "pi"].includes(plan.runtimeName)) return []; + return [ "attempts=0", `until curl -sf ${shellQuote(`http://127.0.0.1:${plan.port}/healthz`)} >/dev/null; do`, diff --git a/src/compiler/containerStateOwnershipRender.ts b/src/compiler/containerStateOwnershipRender.ts new file mode 100644 index 00000000..0e675b8b --- /dev/null +++ b/src/compiler/containerStateOwnershipRender.ts @@ -0,0 +1,103 @@ +import path from "node:path"; + +import { SpawnfileError } from "../shared/index.js"; + +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import { + DAIMON_RUNTIME_UID, + resolveDaimonUidEntrypointStateRoots +} from "./containerDaimonUidEntrypointRender.js"; +import type { EntrypointOptions } from "./containerEntrypointRender.js"; + +const SPAWNFILE_STATE_ROOT = "/var/lib/spawnfile"; +const MOLTNET_STATE_ROOT = `${SPAWNFILE_STATE_ROOT}/moltnet`; + +const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\"'\"'`)}'`; + +const privateDirectoriesThrough = (target: string): string[] => { + const relative = path.posix.relative(SPAWNFILE_STATE_ROOT, target); + if (relative.startsWith("../") || path.posix.isAbsolute(relative)) { + throw new SpawnfileError( + "compile_error", + `Generated Moltnet state path escapes ${SPAWNFILE_STATE_ROOT}: ${target}` + ); + } + const segments = relative === "" ? [] : relative.split("/"); + return [ + SPAWNFILE_STATE_ROOT, + ...segments.map((_, index) => + path.posix.join(SPAWNFILE_STATE_ROOT, ...segments.slice(0, index + 1)) + ) + ]; +}; + +const createMoltnetPrivacyCommands = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[], + moltnet: EntrypointOptions["moltnet"] +): string[] => { + if (!runtimePlans.some((plan) => plan.runtimeName === "daimon") || !moltnet) return []; + + const configPaths = [ + ...moltnet.serverPlans.flatMap((plan) => + plan.mode === "managed" && plan.configPath ? [plan.configPath] : [] + ), + ...moltnet.nodePlans.map((plan) => plan.configPath) + ].sort(); + if (configPaths.length === 0) return []; + if (configPaths.some((configPath) => !configPath.startsWith(`${MOLTNET_STATE_ROOT}/`))) { + throw new SpawnfileError( + "compile_error", + "Generated Moltnet config paths must stay beneath the private Moltnet state root" + ); + } + + const moltnetMountPaths = persistentMountPaths + .filter((mountPath) => mountPath.startsWith(`${MOLTNET_STATE_ROOT}/`)); + const privateDirectories = [ + ...new Set([ + ...configPaths.flatMap((configPath) => + privateDirectoriesThrough(path.posix.dirname(configPath)) + ), + ...moltnetMountPaths.flatMap(privateDirectoriesThrough) + ]) + ].sort(); + const ownership = `${DAIMON_RUNTIME_UID}:${DAIMON_RUNTIME_UID}`; + + 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(" ")}` + ]; +}; + +export const createStateOwnershipCommand = ( + runtimePlans: RuntimeTargetPlan[], + persistentMountPaths: string[] = [], + moltnet?: EntrypointOptions["moltnet"] +): string => { + const mountPaths = [...new Set(persistentMountPaths)].sort(); + const wrapperStateRoots = runtimePlans.some((plan) => plan.runtimeName === "daimon") + ? resolveDaimonUidEntrypointStateRoots(runtimePlans) + : []; + const mkdirPaths = [...new Set([SPAWNFILE_STATE_ROOT, ...mountPaths])].sort(); + const markerCommands = mountPaths.map((mountPath) => + `touch ${shellQuote(path.posix.join(mountPath, ".spawnfile-volume-init"))}` + ); + const chownPaths = [ + ...new Set([ + SPAWNFILE_STATE_ROOT, + ...mountPaths.filter((mountPath) => !mountPath.startsWith(`${SPAWNFILE_STATE_ROOT}/`)) + ]) + ].sort(); + + return [ + `mkdir -p ${mkdirPaths.map(shellQuote).join(" ")}`, + ...markerCommands, + `chown -R spawnfile:spawnfile ${chownPaths.map(shellQuote).join(" ")}`, + ...wrapperStateRoots.map( + (stateRoot) => `install -d -o root -g root -m 700 ${shellQuote(stateRoot)}` + ), + ...createMoltnetPrivacyCommands(runtimePlans, mountPaths, moltnet) + ].join(" && "); +}; diff --git a/src/compiler/daimonTelemetryArtifacts.test.ts b/src/compiler/daimonTelemetryArtifacts.test.ts index 027c3159..9fc43581 100644 --- a/src/compiler/daimonTelemetryArtifacts.test.ts +++ b/src/compiler/daimonTelemetryArtifacts.test.ts @@ -85,7 +85,7 @@ describe("createDaimonTelemetryArtifacts", () => { }); }); - it("mounts telemetry for the daimon runtime name too (the pi-app alias)", () => { + it("does not attach generated-Pi telemetry to a public Daimon host", () => { const plan = createPlan(); const runtimePlan = createRuntimePlan({ id: "pi-app", @@ -96,10 +96,8 @@ describe("createDaimonTelemetryArtifacts", () => { const bundle = createDaimonTelemetryArtifacts(plan, [runtimePlan], compiledNodes); - expect(bundle.mounts.map((mount) => mount.id)).toEqual(["agent-mapper-daimon-telemetry"]); - expect(bundle.telemetryMountIdsByInstance.get("pi-app")).toEqual({ - "agent:mapper": "agent-mapper-daimon-telemetry" - }); + expect(bundle.mounts).toEqual([]); + expect(bundle.telemetryMountIdsByInstance.size).toBe(0); }); it("scopes the volume name to the run id, so two runs of the same project never share telemetry", () => { diff --git a/src/compiler/daimonTelemetryArtifacts.ts b/src/compiler/daimonTelemetryArtifacts.ts index 68fc6079..3f1117ba 100644 --- a/src/compiler/daimonTelemetryArtifacts.ts +++ b/src/compiler/daimonTelemetryArtifacts.ts @@ -7,16 +7,11 @@ import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { CompiledNodeArtifact, RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import type { CompilePlan } from "./types.js"; -/** Runtime names whose generated app is `src/runtime/pi/appCoreSource.ts` (the pi harness - * and its `daimonAdapter` alias — see `src/runtime/pi/adapter.ts`'s - * `export const daimonAdapter: RuntimeAdapter = { ...piAdapter, name: "daimon" }`, which - * shares `piAdapter.container`/`createContainerTargets` byte-for-byte). Both write daimon - * turn/wake causal telemetry the same way, under `runtimeHomePath` (appCoreSource.ts:189), - * so both need the telemetry mount, not just the literal "pi" runtime name. */ -const DAIMON_TELEMETRY_RUNTIME_NAMES = new Set(["daimon", "pi"]); +/** Only the legacy generated Pi application writes this telemetry layout. */ +const PI_TELEMETRY_RUNTIME_NAMES = new Set(["pi"]); export interface DaimonTelemetryArtifactBundle { - /** One durable volume per pi/daimon agent, mounted onto its telemetry directory. */ + /** One durable volume per legacy Pi agent, mounted onto its telemetry directory. */ mounts: ContainerPersistentMountReport[]; /** Runtime target plan id (e.g. "pi-app") -> { node id -> telemetry persistent mount id * }, so `containerArtifacts.ts` can stamp `runtime_instances[].telemetry_mount_ids` and @@ -29,7 +24,7 @@ const createTelemetryMountId = (agentSlug: string): string => `agent-${agentSlug}-daimon-telemetry`; /** - * Registers one run-scoped durable volume per pi/daimon agent for its daimon turn/wake + * Registers one run-scoped durable volume per legacy Pi agent for its daimon turn/wake * causal telemetry directory (`/runtime/agents//telemetry` — the parent * of `causal.jsonl`, see `appCoreSource.ts`'s `runtimeHomePath`/`instanceRoot`), mirroring * exactly how `moltnetArtifacts.ts` mounts the Moltnet causal directory (Piece 4b, @@ -58,7 +53,7 @@ export const createDaimonTelemetryArtifacts = ( const telemetryMountIdsByInstance = new Map>(); for (const runtimePlan of runtimePlans) { - if (!DAIMON_TELEMETRY_RUNTIME_NAMES.has(runtimePlan.runtimeName) || !runtimePlan.instancePaths.homePath) { + if (!PI_TELEMETRY_RUNTIME_NAMES.has(runtimePlan.runtimeName) || !runtimePlan.instancePaths.homePath) { continue; } diff --git a/src/compiler/localMoltnetAuthority.ts b/src/compiler/localMoltnetAuthority.ts new file mode 100644 index 00000000..61bfbf39 --- /dev/null +++ b/src/compiler/localMoltnetAuthority.ts @@ -0,0 +1,176 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { chmod, readFile, rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { ensureDirectory, fileExists } from "../filesystem/index.js"; +import { SpawnfileError } from "../shared/index.js"; +import type { MoltnetTargetArchitecture } from "./moltnetReleaseAuthority.js"; + +const execFile = promisify(execFileCallback); +const SHA256 = /^[a-f0-9]{64}$/u; + +export interface LocalMoltnetReleaseIdentity { + readonly architecture: MoltnetTargetArchitecture; + readonly asset: string; + readonly asset_sha256: `sha256:${string}`; + readonly capabilities: readonly ["daimon-bridge", "pi-bridge"]; + readonly development: Readonly<{ + mode: "local-development"; + non_production: true; + unsigned: true; + unpublished: true; + }>; + readonly source_sha256: `sha256:${string}`; + readonly version: "spawnfile.moltnet-release-identity.v1"; +} + +interface LocalMoltnetReleaseStamp { + readonly arch: MoltnetTargetArchitecture; + readonly asset: string; + readonly capabilities: readonly ["daimon-bridge", "pi-bridge"]; + readonly development: LocalMoltnetReleaseIdentity["development"]; + readonly sha256: string; + readonly source_sha256: `sha256:${string}`; + readonly stamp_version: "spawnfile.local-moltnet-release-stamp.v1"; +} + +const exactKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); + +const assetName = (architecture: MoltnetTargetArchitecture): string => + `moltnet_linux_${architecture}.tar.gz`; + +const bridgeProbeConfig = (kind: "daimon" | "pi"): string => JSON.stringify({ + attachments: [{ + agent: { id: `${kind}-capability-probe`, name: `${kind} capability probe` }, + runtime: kind === "daimon" + ? { + control_url: "http://127.0.0.1:9", + kind, + token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" + } + : { control_url: "http://127.0.0.1:9/agents/pi-capability-probe/wake", kind } + }], + moltnet: { base_url: "http://127.0.0.1:9", network_id: "capability-probe" }, + version: "moltnet.node.v1" +}); + +const assertBridgeCapability = async ( + binaryPath: string, + directory: string, + kind: "daimon" | "pi" +): Promise => { + const configPath = path.join(directory, `${kind}-bridge-probe.json`); + await writeFile(configPath, bridgeProbeConfig(kind), { mode: 0o600 }); + try { + await execFile(binaryPath, ["node", configPath], { + env: { ...process.env, SPAWNFILE_DAIMON_CONTROL_TOKEN: "capability-probe" }, + timeout: 1_000 + }); + throw new SpawnfileError("compile_error", `Local Moltnet binary exited before proving its ${kind}-bridge capability`); + } catch (error) { + if (error instanceof SpawnfileError) throw error; + const execution = error as { code?: unknown; killed?: unknown; signal?: unknown; stderr?: unknown; stdout?: unknown }; + const output = `${String(execution.stdout ?? "")}\n${String(execution.stderr ?? "")}`; + if (/unsupported|only supported|required|invalid|unknown runtime/i.test(output)) { + throw new SpawnfileError("compile_error", `Local Moltnet binary does not accept ${kind}-bridge configuration`); + } + if (execution.killed === true || execution.signal === "SIGTERM" || execution.code === "ETIMEDOUT") return; + if (/connection refused|connect:|dial tcp|network is unreachable/i.test(output)) return; + throw new SpawnfileError("compile_error", `Local Moltnet binary could not prove its ${kind}-bridge capability`); + } +}; + +const parseLocalReleaseStamp = ( + raw: string, + architecture: MoltnetTargetArchitecture +): LocalMoltnetReleaseStamp => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new SpawnfileError("compile_error", "Local Moltnet release stamp is not valid JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new SpawnfileError("compile_error", "Local Moltnet release stamp has an invalid shape"); + } + const value = parsed as Record; + const development = value.development as Record | undefined; + if (!exactKeys(value, ["arch", "asset", "capabilities", "development", "sha256", "source_sha256", "stamp_version"]) + || value.stamp_version !== "spawnfile.local-moltnet-release-stamp.v1" + || value.arch !== architecture + || value.asset !== assetName(architecture) + || !Array.isArray(value.capabilities) + || value.capabilities.join("\0") !== "daimon-bridge\0pi-bridge" + || !development + || !exactKeys(development, ["mode", "non_production", "unsigned", "unpublished"]) + || development.mode !== "local-development" + || development.non_production !== true + || development.unsigned !== true + || development.unpublished !== true + || typeof value.sha256 !== "string" + || !SHA256.test(value.sha256) + || typeof value.source_sha256 !== "string" + || !/^sha256:[a-f0-9]{64}$/u.test(value.source_sha256)) { + throw new SpawnfileError("compile_error", "Local Moltnet release stamp must be a complete development-only dual-bridge identity"); + } + return value as unknown as LocalMoltnetReleaseStamp; +}; + +const verifyBuiltMoltnetArchive = async ( + releaseAssetPath: string, + architecture: MoltnetTargetArchitecture, + hostArchitecture: MoltnetTargetArchitecture +): Promise => { + if (architecture !== hostArchitecture) { + throw new SpawnfileError("compile_error", "Local Moltnet archive architecture cannot be verified on this host"); + } + const temporaryDirectory = path.join(path.dirname(releaseAssetPath), `.spawnfile-moltnet-verify-${process.pid}-${Date.now()}`); + try { + await ensureDirectory(temporaryDirectory); + await execFile("tar", ["-C", temporaryDirectory, "-xzf", releaseAssetPath]); + const binaryPath = path.join(temporaryDirectory, "moltnet"); + if (!(await fileExists(binaryPath))) { + throw new SpawnfileError("compile_error", "Local Moltnet archive does not contain its moltnet binary"); + } + await chmod(binaryPath, 0o755); + const { stdout } = await execFile(binaryPath, ["version"]); + if (!stdout.trim()) { + throw new SpawnfileError("compile_error", "Local Moltnet binary did not produce a bounded version identity"); + } + await assertBridgeCapability(binaryPath, temporaryDirectory, "pi"); + await assertBridgeCapability(binaryPath, temporaryDirectory, "daimon"); + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +}; + +export const readLocalMoltnetReleaseIdentity = async ( + releaseDirectory: string, + architecture: MoltnetTargetArchitecture, + hostArchitecture: MoltnetTargetArchitecture +): Promise => { + const asset = assetName(architecture); + const assetPath = path.join(releaseDirectory, asset); + const stampPath = path.join(releaseDirectory, `local_moltnet_release_stamp_${architecture}.json`); + if (!(await fileExists(assetPath)) || !(await fileExists(stampPath))) { + throw new SpawnfileError("compile_error", "Local Moltnet release requires its exact archive and development identity stamp"); + } + const stamp = parseLocalReleaseStamp(await readFile(stampPath, "utf8"), architecture); + const sha256 = createHash("sha256").update(await readFile(assetPath)).digest("hex"); + if (stamp.sha256 !== sha256) { + throw new SpawnfileError("compile_error", "Local Moltnet development stamp does not match its archive bytes"); + } + await verifyBuiltMoltnetArchive(assetPath, architecture, hostArchitecture); + return Object.freeze({ + architecture, + asset, + asset_sha256: `sha256:${sha256}`, + capabilities: Object.freeze(["daimon-bridge", "pi-bridge"] as const), + development: Object.freeze({ mode: "local-development", non_production: true, unsigned: true, unpublished: true }), + source_sha256: stamp.source_sha256, + version: "spawnfile.moltnet-release-identity.v1" + }); +}; diff --git a/src/compiler/mixedRuntimeOrg.test.ts b/src/compiler/mixedRuntimeOrg.test.ts index 90054ec8..d1847695 100644 --- a/src/compiler/mixedRuntimeOrg.test.ts +++ b/src/compiler/mixedRuntimeOrg.test.ts @@ -68,7 +68,7 @@ describe("mixed runtime org fixture", () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); }); - it("compiles OpenClaw, PicoClaw, and Daimon agents into one container plan", async () => { + it("compiles OpenClaw, PicoClaw, and legacy Pi agents into one container plan", async () => { const previousCli = process.env.SPAWNFILE_MOLTNET_CLI; const previousReleaseDir = process.env.SPAWNFILE_MOLTNET_RELEASE_DIR; process.env.SPAWNFILE_MOLTNET_CLI = await createFakeMoltnetCli(); @@ -82,7 +82,7 @@ describe("mixed runtime org fixture", () => { }); const container = result.report.container; - expect(container?.runtimes_installed).toEqual(["daimon", "openclaw", "picoclaw"]); + expect(container?.runtimes_installed).toEqual(["openclaw", "pi", "picoclaw"]); expect(container?.runtime_instances.map((instance) => ({ id: instance.id, methods: instance.model_auth_methods, @@ -105,7 +105,7 @@ describe("mixed runtime org fixture", () => { id: "pi-app", methods: { local: "none" }, nodes: ["agent:localist"], - runtime: "daimon" + runtime: "pi" } ]); expect(container?.moltnet?.node_plans.map((plan) => plan.network_id).sort()).toEqual([ @@ -117,13 +117,13 @@ describe("mixed runtime org fixture", () => { const piConfig = JSON.parse( await readUtf8File(path.join( outputDirectory, - "container/rootfs/var/lib/spawnfile/instances/daimon/pi-app/pi/pi-app.json" + "container/rootfs/var/lib/spawnfile/instances/pi/pi-app/pi/pi-app.json" )) ); const modelsConfig = JSON.parse( await readUtf8File(path.join( outputDirectory, - "container/rootfs/var/lib/spawnfile/instances/daimon/pi-app/home/.pi/agent/models.json" + "container/rootfs/var/lib/spawnfile/instances/pi/pi-app/home/.pi/agent/models.json" )) ); const provider = piConfig.agents[0]?.model.provider as string; diff --git a/src/compiler/moltnetBinaries.test.ts b/src/compiler/moltnetBinaries.test.ts index e26f756f..d536585c 100644 --- a/src/compiler/moltnetBinaries.test.ts +++ b/src/compiler/moltnetBinaries.test.ts @@ -75,7 +75,13 @@ const createFakeReleaseDirectory = async ( for (const binaryName of binaryNames) { const binaryPath = path.join(payloadDirectory, binaryName); - await writeUtf8File(binaryPath, `#!/usr/bin/env sh\necho ${binaryName}\n`); + await writeUtf8File(binaryPath, [ + "#!/usr/bin/env sh", + "if [ \"$1\" = version ]; then echo moltnet; exit 0; fi", + "if [ \"$1\" = node ]; then sleep 2; exit 0; fi", + `echo ${binaryName}`, + "exit 1" + ].join("\n") + "\n"); await chmod(binaryPath, 0o755); } @@ -146,8 +152,10 @@ afterEach(async () => { describe("moltnetBinaries", () => { it("exposes only the fixed-authority staging surface from the shipped module", () => { expect(Object.keys(shippedMoltnetBinaries).sort()).toEqual([ + "MOLTNET_ALLOW_LOCAL_E2E_ENV", "MOLTNET_BINARY_NAMES", "MOLTNET_BIN_DIRECTORY", + "MOLTNET_LOCAL_RELEASE_DIR_ENV", "MOLTNET_RELEASE_DIR_ENV", "MOLTNET_RELEASE_IDENTITY_VERSION", "MOLTNET_RELEASE_STAMP_VERSION", @@ -227,6 +235,73 @@ describe("moltnetBinaries", () => { .resolves.toBe(false); }); + it("admits a dual-bridge local identity only through explicit E2E opt-in", async () => { + const releaseDirectory = await createFakeReleaseDirectory(); + const architecture = process.arch === "arm64" ? "arm64" : "amd64"; + const asset = `moltnet_linux_${architecture}.tar.gz`; + const sha256 = createHash("sha256").update(await readFile(path.join(releaseDirectory, asset))).digest("hex"); + await writeUtf8File(path.join(releaseDirectory, `local_moltnet_release_stamp_${architecture}.json`), `${JSON.stringify({ + arch: architecture, asset, capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + sha256, source_sha256: `sha256:${"f".repeat(64)}`, + stamp_version: "spawnfile.local-moltnet-release-stamp.v1" + })}\n`); + const outputDirectory = await createTempDirectory("spawnfile-moltnet-local-out-"); + vi.stubEnv("SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR", releaseDirectory); + await expect(stageMoltnetBinaries(outputDirectory, { architecture })).rejects.toThrow(/explicit SPAWNFILE_ALLOW_LOCAL_E2E=1/u); + vi.stubEnv("SPAWNFILE_ALLOW_LOCAL_E2E", "1"); + await expect(stageMoltnetBinaries(outputDirectory, { architecture })).resolves.toMatchObject({ + capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + source_sha256: `sha256:${"f".repeat(64)}` + }); + }); + + it("rejects a dual-bridge stamp when the archived binary rejects Daimon configuration", async () => { + const releaseDirectory = await createFakeReleaseDirectory(); + const architecture = process.arch === "arm64" ? "arm64" : "amd64"; + const asset = `moltnet_linux_${architecture}.tar.gz`; + const binaryPath = path.join(releaseDirectory, "payload", "moltnet"); + await writeUtf8File(binaryPath, [ + "#!/usr/bin/env sh", + "if [ \"$1\" = version ]; then echo moltnet; exit 0; fi", + "if grep -q '\"kind\":\"daimon\"' \"$2\"; then echo unsupported >&2; exit 1; fi", + "sleep 2" + ].join("\n") + "\n"); + await chmod(binaryPath, 0o755); + await execFile("tar", ["-C", path.join(releaseDirectory, "payload"), "-czf", path.join(releaseDirectory, asset), "."]); + const sha256 = createHash("sha256").update(await readFile(path.join(releaseDirectory, asset))).digest("hex"); + await writeUtf8File(path.join(releaseDirectory, `local_moltnet_release_stamp_${architecture}.json`), `${JSON.stringify({ + arch: architecture, asset, capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + sha256, source_sha256: `sha256:${"f".repeat(64)}`, + stamp_version: "spawnfile.local-moltnet-release-stamp.v1" + })}\n`); + const outputDirectory = await createTempDirectory("spawnfile-moltnet-local-out-"); + vi.stubEnv("SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR", releaseDirectory); + vi.stubEnv("SPAWNFILE_ALLOW_LOCAL_E2E", "1"); + + await expect(stageMoltnetBinaries(outputDirectory, { architecture })).rejects.toThrow(/does not accept daimon-bridge/u); + }); + + it("rejects malformed local identities before extraction", async () => { + const releaseDirectory = await createFakeReleaseDirectory(); + const architecture = process.arch === "arm64" ? "arm64" : "amd64"; + const asset = `moltnet_linux_${architecture}.tar.gz`; + const sha256 = createHash("sha256").update(await readFile(path.join(releaseDirectory, asset))).digest("hex"); + await writeUtf8File(path.join(releaseDirectory, `local_moltnet_release_stamp_${architecture}.json`), `${JSON.stringify({ + arch: architecture, asset, capabilities: ["pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + sha256, source_sha256: `sha256:${"f".repeat(64)}`, + stamp_version: "spawnfile.local-moltnet-release-stamp.v1" + })}\n`); + vi.stubEnv("SPAWNFILE_ALLOW_LOCAL_E2E", "1"); + vi.stubEnv("SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR", releaseDirectory); + const outputDirectory = await createTempDirectory("spawnfile-moltnet-local-out-"); + await expect(stageMoltnetBinaries(outputDirectory, { architecture })).rejects.toThrow(/dual-bridge/u); + await expect(fileExists(path.join(outputDirectory, MOLTNET_BIN_DIRECTORY))).resolves.toBe(false); + }); + it("normalizes every supported configured architecture before trust verification", async () => { for (const [configured, architecture] of [ ["amd64", "amd64"], ["x86_64", "amd64"], ["x64", "amd64"], diff --git a/src/compiler/moltnetBinaries.ts b/src/compiler/moltnetBinaries.ts index e41bcaad..d0ca2021 100644 --- a/src/compiler/moltnetBinaries.ts +++ b/src/compiler/moltnetBinaries.ts @@ -14,12 +14,19 @@ import { type TrustedMoltnetReleaseAuthority } from "./moltnetReleaseAuthority.js"; import { downloadTrustedMoltnetReleaseAsset } from "./moltnetReleaseDownload.js"; +import { + readLocalMoltnetReleaseIdentity, + type LocalMoltnetReleaseIdentity +} from "./localMoltnetAuthority.js"; const execFile = promisify(execFileCallback); const MOLTNET_CLI_ENV = "SPAWNFILE_MOLTNET_CLI"; /** Explicit operator override for a locally staged, authority-bound release. */ export const MOLTNET_RELEASE_DIR_ENV = "SPAWNFILE_MOLTNET_RELEASE_DIR"; +/** Explicit development-only local archive authority; never consulted by production compiles. */ +export const MOLTNET_LOCAL_RELEASE_DIR_ENV = "SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR"; +export const MOLTNET_ALLOW_LOCAL_E2E_ENV = "SPAWNFILE_ALLOW_LOCAL_E2E"; const MOLTNET_TARGET_ARCH_ENV = "SPAWNFILE_MOLTNET_TARGET_ARCH"; const MOLTNET_TARGET_OS = "linux"; @@ -29,16 +36,24 @@ export const MOLTNET_RELEASE_IDENTITY_VERSION = "spawnfile.moltnet-release-ident export const MOLTNET_RELEASE_STAMP_VERSION = "spawnfile.moltnet-release-stamp.v1" as const; export type { MoltnetTargetArchitecture } from "./moltnetReleaseAuthority.js"; -export interface MoltnetReleaseIdentity { +export type MoltnetBridgeCapabilities = readonly ["pi-bridge"] | readonly ["daimon-bridge", "pi-bridge"]; + +interface MoltnetIdentityBase { readonly architecture: MoltnetTargetArchitecture; readonly asset: string; readonly asset_sha256: `sha256:${string}`; + readonly capabilities: MoltnetBridgeCapabilities; + readonly version: typeof MOLTNET_RELEASE_IDENTITY_VERSION; +} + +export interface PublishedMoltnetReleaseIdentity extends MoltnetIdentityBase { readonly capabilities: readonly ["pi-bridge"]; readonly release_version: string; readonly source_revision: string; - readonly version: typeof MOLTNET_RELEASE_IDENTITY_VERSION; } +export type MoltnetReleaseIdentity = PublishedMoltnetReleaseIdentity | LocalMoltnetReleaseIdentity; + export interface MoltnetBinaryStageOptions { readonly architecture?: MoltnetTargetArchitecture; /** Explicit local source directory; bytes remain bound to trusted authority. */ @@ -160,7 +175,7 @@ const verifyReleaseIdentity = async ( releaseDirectory: string, architecture: MoltnetTargetArchitecture, authority: TrustedMoltnetReleaseAuthority -): Promise => { +): Promise => { const trustedAuthority = parseTrustedMoltnetReleaseAuthority(authority); const trustedAsset = trustedMoltnetReleaseAsset(trustedAuthority, architecture); const asset = createReleaseAssetName(architecture); @@ -273,6 +288,18 @@ const resolveConfiguredReleaseDirectory = async (): Promise => { return configuredDirectory; }; +const resolveConfiguredLocalReleaseDirectory = async (): Promise => { + const configuredDirectory = process.env[MOLTNET_LOCAL_RELEASE_DIR_ENV]?.trim(); + if (!configuredDirectory) return null; + if (process.env[MOLTNET_ALLOW_LOCAL_E2E_ENV] !== "1") { + throw new SpawnfileError("compile_error", "Local Moltnet identity requires explicit SPAWNFILE_ALLOW_LOCAL_E2E=1 opt-in"); + } + if (!path.isAbsolute(configuredDirectory) || !(await fileExists(configuredDirectory))) { + throw new SpawnfileError("compile_error", "Local Moltnet release directory is invalid"); + } + return configuredDirectory; +}; + const findPathMoltnetCli = async (): Promise => { try { await execFile("moltnet", ["version"]); @@ -326,6 +353,20 @@ export const stageMoltnetBinaries = async ( outputDirectory: string, options: MoltnetBinaryStageOptions = {} ): Promise => { + const localReleaseDirectory = await resolveConfiguredLocalReleaseDirectory(); + if (localReleaseDirectory) { + const architecture = resolveTargetArchitecture(options.architecture); + const identity = await readLocalMoltnetReleaseIdentity( + localReleaseDirectory, + architecture, + resolveTargetArchitecture() + ); + return stageMoltnetReleaseAsset( + outputDirectory, + path.join(localReleaseDirectory, createReleaseAssetName(architecture)), + identity + ); + } const releaseDirectory = options.releaseDirectory ?? await resolveConfiguredReleaseDirectory(); if (releaseDirectory) { diff --git a/src/compiler/moltnetExternalParticipantResolution.test.ts b/src/compiler/moltnetExternalParticipantResolution.test.ts index 41669f1c..abf59837 100644 --- a/src/compiler/moltnetExternalParticipantResolution.test.ts +++ b/src/compiler/moltnetExternalParticipantResolution.test.ts @@ -79,7 +79,14 @@ describe("Moltnet external participant resolution", () => { { authoredMemberKey: "red", kind: "agent", memberId: "alpha.red", principalId: "agent:alpha.red" }, { authoredMemberKey: "red", kind: "agent", memberId: "beta.red", principalId: "agent:beta.red" } ], - externalParticipants: [] + externalParticipants: [ + { + authoredParticipantKey: "world", + kind: "service", + memberId: "world", + principalId: "system:world" + } + ] }, root: root.source, runtimes: {} diff --git a/src/compiler/moltnetNestedOrganization.test.ts b/src/compiler/moltnetNestedOrganization.test.ts index 9e6ab2f9..d27ddffa 100644 --- a/src/compiler/moltnetNestedOrganization.test.ts +++ b/src/compiler/moltnetNestedOrganization.test.ts @@ -196,6 +196,9 @@ describe("nested B31 Moltnet organization composition", () => { const plan = await buildCompilePlan(root); const rootTeam = plan.nodes.find((node) => node.kind === "team")?.value; expect(rootTeam && Object.hasOwn(rootTeam, "externalParticipants")).toBe(false); + expect(plan.organizationIdentity).toMatchObject({ + agentMembers: [{ memberId: "red", principalId: "agent:red" }], externalParticipants: [], + }); const artifacts = await generateMoltnetArtifacts(plan); expect(artifacts && Object.hasOwn(artifacts, "externalParticipantArtifacts")) .toBe(false); diff --git a/src/compiler/moltnetResolution.ts b/src/compiler/moltnetResolution.ts index 6c7ec44a..5571ffae 100644 --- a/src/compiler/moltnetResolution.ts +++ b/src/compiler/moltnetResolution.ts @@ -58,20 +58,9 @@ const validateGlobalMemberIds = (plan: CompilePlan): void => { ); for (const context of uniqueContexts.values()) { - let memberId = context.memberId; - if (plan.organizationIdentity) { - const canonicalMemberId = resolveCanonicalAgentMemberId(plan, context.agentSource); - const matches = plan.organizationIdentity.agentMembers.filter( - (member) => member.memberId === canonicalMemberId - ); - if (!canonicalMemberId || matches.length !== 1) { - throw new SpawnfileError( - "validation_error", - `Unable to resolve exactly one canonical Moltnet member id for ${context.agentSource}` - ); - } - memberId = canonicalMemberId; - } + const memberId = (plan.organizationIdentity?.externalParticipants.length ?? 0) > 0 + ? resolveCanonicalAgentMemberId(plan, context.agentSource) ?? context.memberId + : context.memberId; const previous = seen.get(memberId); const label = `${context.teamName} (${context.teamSource}) member ${context.memberId}`; if ( @@ -298,9 +287,7 @@ export const resolvePlanMoltnetAttachments = (plan: CompilePlan): void => { const { agentSource, attachment, directTeamSource } = synthesized; const representativeContext = (plan.memberships ?? []).find((context) => context.agentSource === agentSource && - (plan.organizationIdentity - ? context.teamSource === directTeamSource - : context.memberId === attachment.memberId) + context.teamSource === directTeamSource ); if (!representativeContext) { throw new SpawnfileError( @@ -339,14 +326,15 @@ export const resolvePlanMoltnetAttachments = (plan: CompilePlan): void => { } const teamNode = findTeamBySource(plan, context.teamSource); - const canonicalMemberId = - resolveCanonicalAgentMemberId(plan, context.agentSource) ?? context.memberId; + const resolvedMemberId = (plan.organizationIdentity?.externalParticipants.length ?? 0) > 0 + ? resolveCanonicalAgentMemberId(plan, context.agentSource) ?? context.memberId + : context.memberId; const resolved = resolveMoltnetAttachments( declaredAttachments, { memberId: context.memberId, networks: teamNode.networks ?? [], - resolvedMemberId: canonicalMemberId, + resolvedMemberId, teamName: context.teamName, teamSource: context.teamSource }, diff --git a/src/compiler/moltnetRoomMemberships.ts b/src/compiler/moltnetRoomMemberships.ts index cc20e264..3a1e3c47 100644 --- a/src/compiler/moltnetRoomMemberships.ts +++ b/src/compiler/moltnetRoomMemberships.ts @@ -55,22 +55,21 @@ const defaultNestedRepresentativePolicy = (): ResolvedMoltnetRoomPolicy => ({ wake: "mentions" }); +// Ordinary Moltnet uses direct member slots. B31's externally authorized +// organization uses its canonical nested principal identifiers instead. const resolveConcreteMemberId = ( plan: CompilePlan, agentSource: string, authoredMemberId: string ): string => { - if (!plan.organizationIdentity) return authoredMemberId; + if ((plan.organizationIdentity?.externalParticipants.length ?? 0) === 0) { + return authoredMemberId; + } const canonicalMemberId = resolveCanonicalAgentMemberId(plan, agentSource); - if ( - !canonicalMemberId || - plan.organizationIdentity.agentMembers.filter((member) => - member.memberId === canonicalMemberId - ).length !== 1 - ) { + if (!canonicalMemberId) { throw new SpawnfileError( "validation_error", - `Unable to resolve exactly one canonical Moltnet member id for ${agentSource}` + `Unable to resolve canonical Moltnet member id for ${agentSource}` ); } return canonicalMemberId; diff --git a/src/compiler/moltnetRuntimeConfig.ts b/src/compiler/moltnetRuntimeConfig.ts index f7fd98d9..ec3b9529 100644 --- a/src/compiler/moltnetRuntimeConfig.ts +++ b/src/compiler/moltnetRuntimeConfig.ts @@ -94,7 +94,20 @@ export const resolveRuntimeConfig = ( kind: "picoclaw" }; } - case "daimon": + case "daimon": { + const port = getRuntimeAdapter("daimon").container.port; + if (!port) { + throw new SpawnfileError( + "compile_error", + `Unable to resolve Daimon control port for Moltnet agent ${agentNode.name}` + ); + } + return { + control_url: `http://127.0.0.1:${port}`, + kind: "daimon", + token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" + }; + } case "pi": { const port = getRuntimeAdapter(agentNode.runtime.name).container.port; if (!port) { diff --git a/src/compiler/organizationExternalParticipants.ts b/src/compiler/organizationExternalParticipants.ts new file mode 100644 index 00000000..d23ff44a --- /dev/null +++ b/src/compiler/organizationExternalParticipants.ts @@ -0,0 +1,200 @@ +import { SpawnfileError } from "../shared/index.js"; +import type { TeamNetworkServer } from "../manifest/index.js"; +import type { + CompilePlan, + MoltnetExternalParticipantIntent, + ResolvedAgentNode, +} from "./types.js"; +import { + assertOrganizationSegment, + compareOrganizationIds, + exactOrganizationStrings, + organizationAgentPaths, + organizationIdentityFail, + requiredOrganizationIdentity, + rootOrganizationTeam, +} from "./organizationIdentityGraph.js"; +const actorTokenFor = ( + server: Extract, + tokenId: string, + memberId: string, + allowObserve = false, +) => { + const token = server.auth.tokens?.filter((entry) => entry.id === tokenId); + if (token?.length !== 1) { + organizationIdentityFail(`Moltnet actor token ${tokenId} must exist exactly once`); + } + const selected = requiredOrganizationIdentity( + token?.[0], `Moltnet actor token ${tokenId} must exist exactly once`, + ); + const validScopes = exactOrganizationStrings(selected.scopes, ["attach", "write"]) + || allowObserve && exactOrganizationStrings(selected.scopes, ["attach", "observe", "write"]); + if (!validScopes || !exactOrganizationStrings(selected.agents, [memberId])) { + organizationIdentityFail(`Moltnet actor token ${tokenId} has invalid scopes or agents for ${memberId}`); + } + return selected; +}; + +const validateB31Networks = (plan: CompilePlan): Set => { + const root = requiredOrganizationIdentity(rootOrganizationTeam(plan), "B31 root team is missing"); + const services = requiredOrganizationIdentity(root.externalParticipants, "B31 participants are missing"); + const networkIds = new Set( + services.flatMap((service) => service.surfaces.moltnet.map((attachment) => attachment.network)), + ); + for (const network of root.networks ?? []) { + if (!networkIds.has(network.id)) continue; + assertOrganizationSegment(network.id, "B31 network id"); + if (network.server?.mode !== "managed" || network.server.auth.mode !== "bearer" + || network.server.direct_messages !== true) { + organizationIdentityFail(`B31 network ${network.id} requires managed bearer direct_messages`); + } + const server = requiredOrganizationIdentity( + network.server, `B31 network ${network.id} requires a managed server`, + ) as Extract; + if (JSON.stringify(server.auth.client) !== JSON.stringify({ token_id: "operator" })) { + organizationIdentityFail(`B31 network ${network.id} requires auth.client token_id operator`); + } + const tokens = server.auth.tokens ?? []; + const operator = tokens.find((token) => token.id === "operator"); + if (tokens.filter((token) => token.id === "operator").length !== 1 || !operator + || operator.agents !== undefined + || !exactOrganizationStrings(operator.scopes, ["admin", "observe", "write"])) { + organizationIdentityFail(`B31 network ${network.id} has invalid operator token`); + } + const resolvedOperator = requiredOrganizationIdentity( + operator, `B31 network ${network.id} has invalid operator token`, + ); + const usedTokenIds = new Set(); + const usedEnvNames = new Set(); + for (const token of tokens) { + assertOrganizationSegment(token.id, "Moltnet token id"); + if (!/^[A-Z_][A-Z0-9_]{0,127}$/u.test(token.secret)) { + organizationIdentityFail(`invalid Moltnet token env name ${token.secret}`); + } + if (usedTokenIds.has(token.id) || usedEnvNames.has(token.secret)) { + organizationIdentityFail(`duplicate Moltnet token identity ${token.id}`); + } + usedTokenIds.add(token.id); + usedEnvNames.add(token.secret); + if (token.id !== "operator" && token.secret === resolvedOperator.secret) { + organizationIdentityFail("operator and actor token env identities must differ"); + } + } + } + return networkIds; +}; + +export const validateB31MoltnetAuth = (plan: CompilePlan): void => { + const root = rootOrganizationTeam(plan); + if (!root?.externalParticipants?.length) return; + const identity = requiredOrganizationIdentity( + plan.organizationIdentity, "B31 organization identity is missing", + ); + const paths = organizationAgentPaths(plan); + const networkIds = validateB31Networks(plan); + const selectedByNetwork = new Map>(); + const selectedActorKeys = new Set(); + for (const member of identity.agentMembers) { + const source = [...paths.entries()].find(([, path]) => path.join(".") === member.memberId)?.[0]; + const node = plan.nodes.find((entry) => entry.kind === "agent" + && (entry.value as ResolvedAgentNode).source === source); + const attachments = (node?.value as ResolvedAgentNode | undefined)?.surfaces?.moltnet ?? []; + for (const attachment of attachments.filter((entry) => networkIds.has(entry.network))) { + const network = root.networks?.find((entry) => entry.id === attachment.network); + const selectedTokenId = requiredOrganizationIdentity( + attachment.auth?.tokenId, `B31 agent ${member.memberId} must select auth.token_id`, + ); + const actorKey = `${attachment.network}\u0000${member.memberId}`; + if (selectedActorKeys.has(actorKey)) { + organizationIdentityFail(`B31 actor ${member.memberId} selects more than one token on ${attachment.network}`); + } + selectedActorKeys.add(actorKey); + if (network?.server?.mode === "managed") { + const token = actorTokenFor(network.server, selectedTokenId, member.memberId); + const selected = selectedByNetwork.get(attachment.network) ?? new Map(); + const previous = selected.get(selectedTokenId); + if (previous) { + organizationIdentityFail(`Moltnet actor token ${selectedTokenId} is shared by ${previous} and ${member.memberId}`); + } + selected.set(selectedTokenId, member.memberId); + selectedByNetwork.set(attachment.network, selected); + if (token.id === "operator") { + organizationIdentityFail(`B31 actor ${member.memberId} must not use operator token`); + } + } + } + } + for (const service of root.externalParticipants) { + for (const attachment of service.surfaces.moltnet) { + const network = root.networks?.find((entry) => entry.id === attachment.network); + if (network?.server?.mode !== "managed") continue; + const token = actorTokenFor(network.server, attachment.auth.token_id, service.id, true); + if (token.id === "operator") { + organizationIdentityFail(`B31 external participant ${service.id} must not use operator token`); + } + const selected = selectedByNetwork.get(attachment.network) ?? new Map(); + const previous = selected.get(token.id); + if (previous) organizationIdentityFail(`Moltnet actor token ${token.id} is shared by ${previous} and ${service.id}`); + selected.set(token.id, service.id); + selectedByNetwork.set(attachment.network, selected); + } + } + for (const network of root.networks ?? []) { + const selected = selectedByNetwork.get(network.id); + if (network.server?.mode !== "managed" || !selected) continue; + for (const token of network.server.auth.tokens ?? []) { + if (token.id !== "operator" && !selected.has(token.id)) { + organizationIdentityFail(`Moltnet actor token ${token.id} is not selected by exactly one actor`); + } + } + } +}; + +export const resolveMoltnetExternalParticipantIntents = ( + plan: CompilePlan, +): MoltnetExternalParticipantIntent[] => { + const identity = plan.organizationIdentity; + const root = rootOrganizationTeam(plan); + if (!identity || !root?.externalParticipants?.length) return []; + const paths = organizationAgentPaths(plan); + const agents = new Map(identity.agentMembers.map((member) => [member.memberId, member])); + const networkIds = new Set((root.networks ?? []).map((network) => network.id)); + const result: MoltnetExternalParticipantIntent[] = []; + for (const service of root.externalParticipants) { + const participant = identity.externalParticipants.find((entry) => entry.memberId === service.id); + if (!participant) { + throw new SpawnfileError("validation_error", `missing external participant identity: ${service.id}`); + } + for (const attachment of service.surfaces.moltnet) { + if (!networkIds.has(attachment.network)) { + organizationIdentityFail(`external participant ${service.id} references unknown network ${attachment.network}`); + } + const peers: string[] = []; + for (const [memberId, agent] of agents) { + const node = plan.nodes.find((entry) => entry.kind === "agent" + && paths.get(entry.value.source)?.join(".") === memberId); + const authored = (node?.value as ResolvedAgentNode | undefined)?.surfaces?.moltnet ?? []; + const eligible = authored.filter((entry) => + entry.network === attachment.network && entry.dms?.enabled === true); + if (eligible.length > 1) { + organizationIdentityFail(`duplicate eligible Moltnet peer for ${service.id}/${attachment.network}`); + } + if (eligible.length === 1) peers.push(agent.memberId); + } + if (new Set(peers).size !== peers.length) { + organizationIdentityFail(`duplicate eligible Moltnet peer for ${service.id}/${attachment.network}`); + } + if (peers.length === 0) { + organizationIdentityFail(`external participant ${service.id}/${attachment.network} has no eligible direct-message peers`); + } + peers.sort(compareOrganizationIds); + const network = root.networks?.find((entry) => entry.id === attachment.network); + const token = network?.server?.mode === "managed" + ? actorTokenFor(network.server, attachment.auth.token_id, service.id, true) + : undefined; + result.push({ participant, networkId: attachment.network, tokenId: attachment.auth.token_id, + tokenEnv: token?.secret ?? "", directMessagePeers: peers }); + } + } + return result; +}; diff --git a/src/compiler/organizationIdentity.test.ts b/src/compiler/organizationIdentity.test.ts index 76463b8a..5b335a54 100644 --- a/src/compiler/organizationIdentity.test.ts +++ b/src/compiler/organizationIdentity.test.ts @@ -369,11 +369,14 @@ describe("organization identity", () => { expect(() => resolveMoltnetExternalParticipantIntents(empty)).toThrow(/no eligible/u); }); - it("leaves the no-external legacy graph and auth path inactive", () => { + it("derives the no-external agent graph while leaving external auth inactive", () => { const current = plan(); rootOf(current).externalParticipants = undefined; current.nodes.push({ id: "legacy-extra", kind: "agent", runtimeName: "pi", slug: "legacy-extra", value: { kind: "agent", name: "legacy-extra", source: "/legacy/extra" } as never }); - expect(resolveOrganizationIdentity(current)).toBeUndefined(); + current.organizationIdentity = resolveOrganizationIdentity(current); + expect(current.organizationIdentity).toMatchObject({ + agentMembers: [{ memberId: "red", principalId: "agent:red" }], externalParticipants: [], + }); expect(resolveMoltnetExternalParticipantIntents(current)).toEqual([]); expect(() => validateB31MoltnetAuth(current)).not.toThrow(); }); diff --git a/src/compiler/organizationIdentity.ts b/src/compiler/organizationIdentity.ts index 4e93d2df..d72c93fa 100644 --- a/src/compiler/organizationIdentity.ts +++ b/src/compiler/organizationIdentity.ts @@ -1,31 +1,32 @@ -import { SpawnfileError } from "../shared/index.js"; -import type { CompilePlan, MoltnetExternalParticipantIntent, ResolvedAgentNode, ResolvedTeamNode } from "./types.js"; -import type { TeamNetworkServer } from "../manifest/index.js"; +import type { CompilePlan, ResolvedTeamNode } from "./types.js"; +import { + assertOrganizationSegment, + compareOrganizationIds, + freezeOrganizationIdentity, + MAX_EXTERNAL_PARTICIPANTS, + MAX_ORGANIZATION_AGENT_MEMBERS, + MAX_ORGANIZATION_MEMBER_DEPTH, + MAX_ORGANIZATION_MEMBER_ID_BYTES, + ORGANIZATION_MEMBER_ID_PATTERN_SOURCE, + organizationAgentPaths, + organizationIdentityFail, + rootOrganizationTeam, +} from "./organizationIdentityGraph.js"; -export const ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE = "^[a-z][a-z0-9-]{0,62}$"; -export const ORGANIZATION_MEMBER_ID_PATTERN_SOURCE = "^[a-z][a-z0-9-]{0,62}(\\.[a-z][a-z0-9-]{0,62}){0,7}$"; -export const MAX_ORGANIZATION_MEMBER_DEPTH = 8; -export const MAX_ORGANIZATION_MEMBER_ID_BYTES = 255; -export const MAX_ORGANIZATION_AGENT_MEMBERS = 128; -export const MAX_EXTERNAL_PARTICIPANTS = 32; +export { + MAX_EXTERNAL_PARTICIPANTS, + MAX_ORGANIZATION_AGENT_MEMBERS, + MAX_ORGANIZATION_MEMBER_DEPTH, + MAX_ORGANIZATION_MEMBER_ID_BYTES, + ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE, + ORGANIZATION_MEMBER_ID_PATTERN_SOURCE, +} from "./organizationIdentityGraph.js"; +export { + resolveMoltnetExternalParticipantIntents, + validateB31MoltnetAuth, +} from "./organizationExternalParticipants.js"; -const segment = new RegExp(ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE, "u"); const memberIdPattern = new RegExp(ORGANIZATION_MEMBER_ID_PATTERN_SOURCE, "u"); -const fail = (message: string): never => { throw new SpawnfileError("validation_error", message); }; -const required = (value: T, message: string): NonNullable => value == null ? fail(message) : value as NonNullable; -const assertSegment = (value: string, label: string): void => { - if (!segment.test(value)) fail(`${label} must match ${ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE}`); -}; -const freeze = (value: T): T => { - if (value && typeof value === "object") { - Object.freeze(value); - for (const child of Object.values(value as Record)) freeze(child); - } - return value; -}; -const byAscii = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; -const exact = (actual: readonly string[] | undefined, expected: readonly string[]): boolean => - actual?.length === expected.length && actual.every((value, index) => value === expected[index]); export interface ResolvedOrganizationAgentMember { readonly authoredMemberKey: string; @@ -44,251 +45,63 @@ export interface ResolvedOrganizationIdentity { readonly externalParticipants: readonly ResolvedExternalParticipant[]; } -const teamNodes = (plan: CompilePlan): Map => new Map( - plan.nodes.filter((node) => node.kind === "team").map((node) => [node.value.source, node.value as ResolvedTeamNode]) -); - -const rootTeam = (plan: CompilePlan): ResolvedTeamNode | undefined => { - const node = plan.nodes.find((entry) => entry.id === plan.root || entry.value.source === plan.root); - return node?.value.kind === "team" ? node.value : undefined; +export const resolveCanonicalAgentMemberId = ( + plan: CompilePlan, + agentSource: string, +): string | undefined => { + if (!plan.organizationIdentity) return undefined; + return organizationAgentPaths(plan).get(agentSource)?.join("."); }; -const pathsToAgents = (plan: CompilePlan): Map => { - const teams = teamNodes(plan); - const edges = plan.edges.filter((edge) => edge.kind === "team_member"); - const paths = new Map(); - const teamPaths = new Map(); - const visitedNodeIds = new Set(); - const walk = (source: string, prefix: string[], seen: Set): void => { - if (seen.has(source)) fail(`organization member graph cycle at ${source}`); - const team = teams.get(source); - if (!team) return; - const priorTeamPath = teamPaths.get(source); - if (priorTeamPath) fail(`team is reached through multiple organization paths: ${source}`); - teamPaths.set(source, prefix); - const node = plan.nodes.find((entry) => entry.value.source === source); - if (!node || node.kind !== "team") return fail(`organization graph references missing team ${source}`); - const resolvedNode = node; - visitedNodeIds.add(resolvedNode.id); - const outgoing = edges - .filter((entry) => entry.from === resolvedNode.id) - .sort((left, right) => byAscii(left.label, right.label)); - const memberSlots = new Set(team.members.map((member) => member.id)); - if (memberSlots.size !== team.members.length || outgoing.length !== team.members.length) { - fail(`organization graph cardinality mismatch for team ${team.name}`); - } - const localSlots = new Set(); - for (const edge of outgoing) { - if (localSlots.has(edge.label)) fail(`duplicate organization member slot: ${edge.label}`); - localSlots.add(edge.label); - assertSegment(edge.label, "organization member slot"); - const next = [...prefix, edge.label]; - if (next.length > MAX_ORGANIZATION_MEMBER_DEPTH) fail("organization member depth exceeds 8"); - const child = plan.nodes.find((node) => node.id === edge.to); - if (!child) throw new SpawnfileError("validation_error", `organization graph references missing node ${edge.to}`); - const resolvedChild = child; - const member = team.members.find((entry) => entry.id === edge.label); - if (!member || member.kind !== resolvedChild.kind || member.nodeSource !== resolvedChild.value.source) { - fail(`organization graph member edge mismatch: ${edge.label}`); - } - if (resolvedChild.kind === "agent") { - const previous = paths.get(resolvedChild.value.source); - if (previous) fail(`agent source is reached through multiple organization paths: ${resolvedChild.value.source}`); - paths.set(resolvedChild.value.source, next); - visitedNodeIds.add(resolvedChild.id); - } else walk(resolvedChild.value.source, next, new Set([...seen, source])); - } - }; - walk(plan.root, [], new Set()); - const rootNode = plan.nodes.find((node) => node.value.source === plan.root); - const organizationNodeIds = new Set(rootNode ? [rootNode.id] : []); - for (const edge of edges) { - organizationNodeIds.add(edge.from); - organizationNodeIds.add(edge.to); - } - const extra = plan.nodes.find((node) => - organizationNodeIds.has(node.id) && !visitedNodeIds.has(node.id) - ); - if (extra) fail(`unreachable organization graph node: ${extra.id}`); - return paths; -}; - -export const resolveCanonicalAgentMemberId = (plan: CompilePlan, agentSource: string): string | undefined => { - const identity = plan.organizationIdentity; - if (!identity) return undefined; - const path = pathsToAgents(plan).get(agentSource); - return path?.join("."); -}; - -export const resolveOrganizationIdentity = (plan: CompilePlan): ResolvedOrganizationIdentity | undefined => { - const root = rootTeam(plan); - const declaredTeams = plan.nodes.filter((node) => node.kind === "team" && (node.value as ResolvedTeamNode).externalParticipants !== undefined); +export const resolveOrganizationIdentity = ( + plan: CompilePlan, +): ResolvedOrganizationIdentity | undefined => { + const root = rootOrganizationTeam(plan); + const declaredTeams = plan.nodes.filter((node) => node.kind === "team" + && ((node.value as ResolvedTeamNode).externalParticipants?.length ?? 0) > 0); if (declaredTeams.some((node) => node.value.source !== root?.source)) { - fail("external_participants may only be declared on the root team"); + organizationIdentityFail("external_participants may only be declared on the root team"); } - if (!root?.externalParticipants) return undefined; - const paths = pathsToAgents(plan); + if (!root) return undefined; + const paths = organizationAgentPaths(plan); const agents: ResolvedOrganizationAgentMember[] = []; const agentIds = new Set(); for (const node of plan.nodes.filter((entry) => entry.kind === "agent")) { const path = paths.get(node.value.source); if (!path) continue; - if (path.length > MAX_ORGANIZATION_MEMBER_DEPTH) fail("organization member depth exceeds 8"); - path.forEach((part) => assertSegment(part, "organization member slot")); + if (path.length > MAX_ORGANIZATION_MEMBER_DEPTH) { + organizationIdentityFail("organization member depth exceeds 8"); + } + path.forEach((part) => assertOrganizationSegment(part, "organization member slot")); const memberId = path.join("."); - if (!memberIdPattern.test(memberId) || Buffer.byteLength(memberId, "ascii") > MAX_ORGANIZATION_MEMBER_ID_BYTES) fail(`invalid organization member id: ${memberId}`); - if (agentIds.has(memberId)) fail(`duplicate organization member id: ${memberId}`); + if (!memberIdPattern.test(memberId) + || Buffer.byteLength(memberId, "ascii") > MAX_ORGANIZATION_MEMBER_ID_BYTES) { + organizationIdentityFail(`invalid organization member id: ${memberId}`); + } + if (agentIds.has(memberId)) organizationIdentityFail(`duplicate organization member id: ${memberId}`); agentIds.add(memberId); agents.push({ authoredMemberKey: path.at(-1) as string, kind: "agent", memberId, principalId: `agent:${memberId}` }); } - if (agents.length > MAX_ORGANIZATION_AGENT_MEMBERS) fail("organization has too many agent members"); + if (agents.length > MAX_ORGANIZATION_AGENT_MEMBERS) { + organizationIdentityFail("organization has too many agent members"); + } const seen = new Set(); - const externalParticipants = root.externalParticipants.map((service) => { - assertSegment(service.id, "external participant id"); - if (seen.has(service.id)) fail(`organization member collision: ${service.id}`); + const externalParticipants = (root.externalParticipants ?? []).map((service) => { + assertOrganizationSegment(service.id, "external participant id"); + if (seen.has(service.id)) organizationIdentityFail(`organization member collision: ${service.id}`); seen.add(service.id); - return { authoredParticipantKey: service.id, kind: "service" as const, memberId: service.id, principalId: `system:${service.id}` }; + return { authoredParticipantKey: service.id, kind: "service" as const, + memberId: service.id, principalId: `system:${service.id}` }; }); - for (const agent of agents) if (seen.has(agent.memberId)) fail(`organization member collision: ${agent.memberId}`); - if (externalParticipants.length > MAX_EXTERNAL_PARTICIPANTS) fail("too many external participants"); - const result = { agentMembers: agents.sort((a, b) => byAscii(a.memberId, b.memberId)), externalParticipants: externalParticipants.sort((a, b) => byAscii(a.memberId, b.memberId)) }; - return freeze(structuredClone(result)); -}; - -const actorTokenFor = ( - server: Extract, - tokenId: string, - memberId: string, - allowObserve = false -) => { - const token = server.auth.tokens?.filter((entry) => entry.id === tokenId); - if (token?.length !== 1) fail(`Moltnet actor token ${tokenId} must exist exactly once`); - const selected = required(token?.[0], `Moltnet actor token ${tokenId} must exist exactly once`); - const validScopes = exact(selected.scopes, ["attach", "write"]) - || allowObserve && exact(selected.scopes, ["attach", "observe", "write"]); - if (!validScopes || !exact(selected.agents, [memberId])) { - fail(`Moltnet actor token ${tokenId} has invalid scopes or agents for ${memberId}`); + for (const agent of agents) { + if (seen.has(agent.memberId)) organizationIdentityFail(`organization member collision: ${agent.memberId}`); } - return selected; -}; - -export const validateB31MoltnetAuth = (plan: CompilePlan): void => { - const root = rootTeam(plan); - if (!root?.externalParticipants) return; - const identity = plan.organizationIdentity; - const resolvedIdentity = required(identity, "B31 organization identity is missing"); - const paths = pathsToAgents(plan); - const agentBySource = new Map(resolvedIdentity.agentMembers.map((member) => { - const source = [...paths.entries()].find(([, path]) => path.join(".") === member.memberId)?.[0]; - return [source, member] as const; - })); - const networkIds = new Set( - root.externalParticipants.flatMap((service) => service.surfaces.moltnet.map((attachment) => attachment.network)) - ); - for (const network of root.networks ?? []) { - if (!root.externalParticipants.some((service) => service.surfaces.moltnet.some((attachment) => attachment.network === network.id))) continue; - assertSegment(network.id, "B31 network id"); - if (network.server?.mode !== "managed" || network.server.auth.mode !== "bearer" || network.server.direct_messages !== true) { - fail(`B31 network ${network.id} requires managed bearer direct_messages`); - } - const server = network.server; - const managedServer = required(server, `B31 network ${network.id} requires a managed server`); - if (JSON.stringify(managedServer.auth.client) !== JSON.stringify({ token_id: "operator" })) { - fail(`B31 network ${network.id} requires auth.client token_id operator`); - } - const tokens = managedServer.auth.tokens ?? []; - const operator = tokens.find((token) => token.id === "operator"); - if (tokens.filter((token) => token.id === "operator").length !== 1 || !operator || operator.agents !== undefined || !exact(operator.scopes, ["admin", "observe", "write"])) { - fail(`B31 network ${network.id} has invalid operator token`); - } - const operatorSecret = operator?.secret; - const usedTokenIds = new Set(); - const usedEnvNames = new Set(); - for (const token of tokens) { - assertSegment(token.id, "Moltnet token id"); - if (!/^[A-Z_][A-Z0-9_]{0,127}$/u.test(token.secret)) fail(`invalid Moltnet token env name ${token.secret}`); - if (usedTokenIds.has(token.id) || usedEnvNames.has(token.secret)) fail(`duplicate Moltnet token identity ${token.id}`); - usedTokenIds.add(token.id); usedEnvNames.add(token.secret); - if (token.id !== "operator" && token.secret === operatorSecret) fail("operator and actor token env identities must differ"); - } - } - const selectedByNetwork = new Map>(); - const selectedActorKeys = new Set(); - for (const member of resolvedIdentity.agentMembers) { - const source = [...agentBySource.entries()].find(([, value]) => value.memberId === member.memberId)?.[0]; - const node = plan.nodes.find((entry) => entry.kind === "agent" && (entry.value as ResolvedAgentNode).source === source); - const attachments = (node?.value as ResolvedAgentNode | undefined)?.surfaces?.moltnet ?? []; - for (const attachment of attachments.filter((entry) => networkIds.has(entry.network))) { - const network = root.networks?.find((entry) => entry.id === attachment.network); - const selectedTokenId = required(attachment.auth?.tokenId, `B31 agent ${member.memberId} must select auth.token_id`); - const actorKey = `${attachment.network}\u0000${member.memberId}`; - if (selectedActorKeys.has(actorKey)) fail(`B31 actor ${member.memberId} selects more than one token on ${attachment.network}`); - selectedActorKeys.add(actorKey); - if (network?.server?.mode === "managed") { - const token = actorTokenFor(network.server, selectedTokenId, member.memberId); - const selected = selectedByNetwork.get(attachment.network) ?? new Map(); - const previous = selected.get(selectedTokenId); - if (previous) fail(`Moltnet actor token ${selectedTokenId} is shared by ${previous} and ${member.memberId}`); - selected.set(selectedTokenId, member.memberId); - selectedByNetwork.set(attachment.network, selected); - if (token.id === "operator") fail(`B31 actor ${member.memberId} must not use operator token`); - } - } + if (externalParticipants.length > MAX_EXTERNAL_PARTICIPANTS) { + organizationIdentityFail("too many external participants"); } - for (const service of root.externalParticipants) { - for (const attachment of service.surfaces.moltnet) { - const network = root.networks?.find((entry) => entry.id === attachment.network); - if (network?.server?.mode !== "managed") continue; - const token = actorTokenFor(network.server, attachment.auth.token_id, service.id, true); - if (token.id === "operator") fail(`B31 external participant ${service.id} must not use operator token`); - const selected = selectedByNetwork.get(attachment.network) ?? new Map(); - const previous = selected.get(token.id); - if (previous) fail(`Moltnet actor token ${token.id} is shared by ${previous} and ${service.id}`); - selected.set(token.id, service.id); - selectedByNetwork.set(attachment.network, selected); - } - } - for (const network of root.networks ?? []) { - const selected = selectedByNetwork.get(network.id); - if (network.server?.mode === "managed" && selected) { - for (const token of network.server.auth.tokens ?? []) { - if (token.id !== "operator" && !selected.has(token.id)) { - fail(`Moltnet actor token ${token.id} is not selected by exactly one actor`); - } - } - } - } -}; - -export const resolveMoltnetExternalParticipantIntents = (plan: CompilePlan): MoltnetExternalParticipantIntent[] => { - const identity = plan.organizationIdentity; - const root = rootTeam(plan); - if (!identity || !root?.externalParticipants) return []; - const paths = pathsToAgents(plan); - const agents = new Map(identity.agentMembers.map((member) => [member.memberId, member])); - const networkIds = new Set((root.networks ?? []).map((network) => network.id)); - const result: MoltnetExternalParticipantIntent[] = []; - for (const service of root.externalParticipants) { - const participant = identity.externalParticipants.find((entry) => entry.memberId === service.id); - if (!participant) throw new SpawnfileError("validation_error", `missing external participant identity: ${service.id}`); - const resolvedParticipant = participant; - for (const attachment of service.surfaces.moltnet) { - if (!networkIds.has(attachment.network)) fail(`external participant ${service.id} references unknown network ${attachment.network}`); - const peers: string[] = []; - for (const [memberId, agent] of agents) { - const node = plan.nodes.find((entry) => entry.kind === "agent" && paths.get(entry.value.source)?.join(".") === memberId); - const authored = (node?.value as ResolvedAgentNode | undefined)?.surfaces?.moltnet ?? []; - const eligible = authored.filter((entry) => entry.network === attachment.network && entry.dms?.enabled === true); - if (eligible.length > 1) fail(`duplicate eligible Moltnet peer for ${service.id}/${attachment.network}`); - if (eligible.length === 1) peers.push(agent.memberId); - } - if (new Set(peers).size !== peers.length) fail(`duplicate eligible Moltnet peer for ${service.id}/${attachment.network}`); - if (peers.length === 0) fail(`external participant ${service.id}/${attachment.network} has no eligible direct-message peers`); - peers.sort(byAscii); - const network = root.networks?.find((entry) => entry.id === attachment.network); - const token = network?.server?.mode === "managed" ? actorTokenFor(network.server, attachment.auth.token_id, service.id, true) : undefined; - result.push({ participant: resolvedParticipant, networkId: attachment.network, tokenId: attachment.auth.token_id, tokenEnv: token?.secret ?? "", directMessagePeers: peers }); - } - } - return result; + const result = { + agentMembers: agents.sort((a, b) => compareOrganizationIds(a.memberId, b.memberId)), + externalParticipants: externalParticipants.sort((a, b) => compareOrganizationIds(a.memberId, b.memberId)), + }; + return freezeOrganizationIdentity(structuredClone(result)); }; diff --git a/src/compiler/organizationIdentityGraph.ts b/src/compiler/organizationIdentityGraph.ts new file mode 100644 index 00000000..c41a68c8 --- /dev/null +++ b/src/compiler/organizationIdentityGraph.ts @@ -0,0 +1,128 @@ +import { SpawnfileError } from "../shared/index.js"; +import type { CompilePlan, ResolvedTeamNode } from "./types.js"; + +export const ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE = "^[a-z][a-z0-9-]{0,62}$"; +export const ORGANIZATION_MEMBER_ID_PATTERN_SOURCE = "^[a-z][a-z0-9-]{0,62}(\\.[a-z][a-z0-9-]{0,62}){0,7}$"; +export const MAX_ORGANIZATION_MEMBER_DEPTH = 8; +export const MAX_ORGANIZATION_MEMBER_ID_BYTES = 255; +export const MAX_ORGANIZATION_AGENT_MEMBERS = 128; +export const MAX_EXTERNAL_PARTICIPANTS = 32; + +const segment = new RegExp(ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE, "u"); + +export const organizationIdentityFail = (message: string): never => { + throw new SpawnfileError("validation_error", message); +}; +export const requiredOrganizationIdentity = ( + value: T, + message: string, +): NonNullable => value == null ? organizationIdentityFail(message) : value as NonNullable; +export const assertOrganizationSegment = (value: string, label: string): void => { + if (!segment.test(value)) { + organizationIdentityFail(`${label} must match ${ORGANIZATION_ID_SEGMENT_PATTERN_SOURCE}`); + } +}; +export const freezeOrganizationIdentity = (value: T): T => { + if (value && typeof value === "object") { + Object.freeze(value); + for (const child of Object.values(value as Record)) { + freezeOrganizationIdentity(child); + } + } + return value; +}; +export const compareOrganizationIds = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; +export const exactOrganizationStrings = ( + actual: readonly string[] | undefined, + expected: readonly string[], +): boolean => actual?.length === expected.length + && actual.every((value, index) => value === expected[index]); + +const teamNodes = (plan: CompilePlan): Map => new Map( + plan.nodes.filter((node) => node.kind === "team") + .map((node) => [node.value.source, node.value as ResolvedTeamNode]), +); + +export const rootOrganizationTeam = (plan: CompilePlan): ResolvedTeamNode | undefined => { + const node = plan.nodes.find((entry) => entry.id === plan.root || entry.value.source === plan.root); + return node?.value.kind === "team" ? node.value : undefined; +}; + +export const organizationAgentPaths = (plan: CompilePlan): Map => { + const teams = teamNodes(plan); + const edges = plan.edges.filter((edge) => edge.kind === "team_member"); + const paths = new Map(); + const teamPaths = new Map(); + const visitedNodeIds = new Set(); + // External-participant authority needs one unambiguous path per principal. + // Ordinary organizations still have a canonical identity, but retain the + // compiler's long-standing ability to reuse an identical agent/team ref. + const requireUniquePaths = (rootOrganizationTeam(plan)?.externalParticipants?.length ?? 0) > 0; + const walk = (source: string, prefix: string[], seen: Set): void => { + if (seen.has(source)) organizationIdentityFail(`organization member graph cycle at ${source}`); + const team = teams.get(source); + if (!team) return; + if (teamPaths.has(source)) { + if (requireUniquePaths) { + organizationIdentityFail(`team is reached through multiple organization paths: ${source}`); + } + return; + } + teamPaths.set(source, prefix); + const node = plan.nodes.find((entry) => entry.value.source === source); + if (!node || node.kind !== "team") { + throw new SpawnfileError("validation_error", `organization graph references missing team ${source}`); + } + visitedNodeIds.add(node.id); + const outgoing = edges.filter((entry) => entry.from === node.id) + .sort((left, right) => compareOrganizationIds(left.label, right.label)); + const memberSlots = new Set(team.members.map((member) => member.id)); + if (memberSlots.size !== team.members.length || outgoing.length !== team.members.length) { + organizationIdentityFail(`organization graph cardinality mismatch for team ${team.name}`); + } + const localSlots = new Set(); + for (const edge of outgoing) { + if (localSlots.has(edge.label)) { + organizationIdentityFail(`duplicate organization member slot: ${edge.label}`); + } + localSlots.add(edge.label); + assertOrganizationSegment(edge.label, "organization member slot"); + const next = [...prefix, edge.label]; + if (next.length > MAX_ORGANIZATION_MEMBER_DEPTH) { + organizationIdentityFail("organization member depth exceeds 8"); + } + const child = plan.nodes.find((candidate) => candidate.id === edge.to); + if (!child) { + throw new SpawnfileError("validation_error", `organization graph references missing node ${edge.to}`); + } + const member = team.members.find((entry) => entry.id === edge.label); + if (!member || member.kind !== child.kind || member.nodeSource !== child.value.source) { + organizationIdentityFail(`organization graph member edge mismatch: ${edge.label}`); + } + if (child.kind === "agent") { + if (paths.has(child.value.source) && requireUniquePaths) { + organizationIdentityFail(`agent source is reached through multiple organization paths: ${child.value.source}`); + } + const prior = paths.get(child.value.source); + if (!prior || compareOrganizationIds(next.join("."), prior.join(".")) < 0) { + paths.set(child.value.source, next); + } + visitedNodeIds.add(child.id); + } else { + walk(child.value.source, next, new Set([...seen, source])); + } + } + }; + walk(plan.root, [], new Set()); + const rootNode = plan.nodes.find((node) => node.value.source === plan.root); + const organizationNodeIds = new Set(rootNode ? [rootNode.id] : []); + for (const edge of edges) { + organizationNodeIds.add(edge.from); + organizationNodeIds.add(edge.to); + } + const extra = plan.nodes.find((node) => + organizationNodeIds.has(node.id) && !visitedNodeIds.has(node.id)); + if (extra) organizationIdentityFail(`unreachable organization graph node: ${extra.id}`); + return paths; +}; diff --git a/src/compiler/publicDaimonHost.test.ts b/src/compiler/publicDaimonHost.test.ts new file mode 100644 index 00000000..39dbc427 --- /dev/null +++ b/src/compiler/publicDaimonHost.test.ts @@ -0,0 +1,61 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { readUtf8File, removeDirectory } from "../filesystem/index.js"; + +import { compileProject } from "./compileProject.js"; + +const temporaryDirectories: string[] = []; +const fixture = path.resolve(process.cwd(), "examples", "daimon-public-host"); + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); +}); + +describe("public Daimon host fixture", () => { + it("emits one strict public host config, launcher, and pinned generic image receipt check", async () => { + const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-public-daimon-")); + temporaryDirectories.push(outputDirectory); + + const result = await compileProject(fixture, { outputDirectory }); + const container = result.report.container; + const instance = container?.runtime_instances.find((candidate) => candidate.runtime === "daimon"); + const configPath = path.join( + outputDirectory, + "container/rootfs/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json" + ); + const launcherPath = path.join( + outputDirectory, + "container/rootfs/opt/spawnfile/runtime-installs/daimon/daimon-start.sh" + ); + + expect(container?.runtimes_installed).toEqual(["daimon"]); + expect(instance).toMatchObject({ + config_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json", + engine_by_node_id: { "agent:public-host-agent": "codex" }, + id: "daimon-organization", + model_auth_methods: {}, + model_secrets_required: [], + node_ids: ["agent:public-host-agent"] + }); + expect(container?.moltnet).toBeUndefined(); + + await expect(readUtf8File(configPath)).resolves.toContain('"version": "noopolis.daimon.organization-runtime.v1"'); + const launcher = await readUtf8File(launcherPath); + expect(launcher).toContain("exec daimon-runtime run --config /var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json"); + expect(launcher).not.toContain(""); + + const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); + expect(dockerfile).toContain("COPY --from=noopolis/spawnfile-runtime-daimon@sha256:"); + expect(dockerfile).toContain("capability-receipt.json"); + expect(dockerfile).toContain( + "install -d -o root -g root -m 700 '/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes'" + ); + expect(dockerfile).toContain('USER root\nENTRYPOINT ["/opt/spawnfile/daimon-uid-entrypoint.sh"]'); + expect(dockerfile).not.toContain("USER spawnfile"); + expect(dockerfile).not.toContain("npm install --omit=dev --no-fund --no-audit @noopolis/daimon"); + }); +}); diff --git a/src/compiler/runProject.runner.test.ts b/src/compiler/runProject.runner.test.ts index 84bba7e1..1c0a628a 100644 --- a/src/compiler/runProject.runner.test.ts +++ b/src/compiler/runProject.runner.test.ts @@ -18,6 +18,7 @@ const detachedContainerId = "a".repeat(64); const detachedImageId = `sha256:${"b".repeat(64)}`; const detachedInspectStdout = [ JSON.stringify(detachedContainerId), + JSON.stringify("/spawnfile-agent"), JSON.stringify(detachedImageId), JSON.stringify({}) ].join("\n"); @@ -126,6 +127,7 @@ describe("runDockerContainer", () => { await expect(promise).resolves.toEqual({ containerId: detachedContainerId, + containerName: "spawnfile-agent", imageId: detachedImageId }); expect(spawn).toHaveBeenCalledWith( @@ -143,7 +145,7 @@ describe("runDockerContainer", () => { "remote", "inspect", "--format", - "{{json .Id}}\n{{json .Image}}\n{{json .Config.Labels}}", + "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", detachedContainerId ], { cwd: "/tmp/spawnfile-run", timeout: 10_000 }, @@ -151,6 +153,30 @@ describe("runDockerContainer", () => { ); }); + it("captures inspected detached identity before the start-boundary callback", async () => { + const child = createFakeDetachedChild(); + const events: string[] = []; + const { runDockerContainer } = await loadRunProjectModule( + child, + (_file, _args, _options, callback) => { + events.push("inspect"); + callback(null, { stderr: "", stdout: `${detachedInspectStdout}\n` }); + } + ); + const promise = runDockerContainer({ + args: ["run", "-d", "--name", "spawnfile-agent", "spawnfile-agent"], command: "docker", + containerName: "spawnfile-agent", cwd: "/tmp/spawnfile-run", detach: true, + envFilePath: "/tmp/spawnfile-run.env", imageTag: "spawnfile-agent", supportDirectory: "/tmp/spawnfile-run", + onDetachedStarted: async (result) => { + events.push(`capture:${result.containerId}:${result.containerName}`); + } + }); + child.stdout?.emit("data", `${detachedContainerId}\n`); + child.emit("exit", 0, null); + await expect(promise).resolves.toMatchObject({ containerId: detachedContainerId, containerName: "spawnfile-agent" }); + expect(events).toEqual(["inspect", `capture:${detachedContainerId}:spawnfile-agent`]); + }); + it("stages bind mounts for SSH docker contexts before running remotely", async () => { const child = createFakeDetachedChild(); const { execFile, runDockerContainer, spawn } = await loadRunProjectModule( @@ -209,6 +235,7 @@ describe("runDockerContainer", () => { await expect(promise).resolves.toEqual({ containerId: detachedContainerId, + containerName: "spawnfile-agent", imageId: detachedImageId }); expect(execFile).toHaveBeenCalledWith( diff --git a/src/compiler/runProject.test.ts b/src/compiler/runProject.test.ts index 8fa3f824..6d73de50 100644 --- a/src/compiler/runProject.test.ts +++ b/src/compiler/runProject.test.ts @@ -398,6 +398,60 @@ describe("createDockerRunInvocation", () => { await removeDirectory(invocation.supportDirectory); }); + it("renders one stable AGY realm volume plus an opaque read-only unlock mount", async () => { + const outputDirectory = await createTempDirectory("spawnfile-agy-run-out-"); + const unlockDirectory = await createTempDirectory("spawnfile-agy-unlock-"); + const unlockPath = path.join(unlockDirectory, "unlock"); + 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: [{ + engine: { kind: "agy" }, id: "agent:agy", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" + }], + host: {}, + version: "noopolis.daimon.organization-runtime.v1" + })); + await writeUtf8File(unlockPath, "unlock-canary"); + await (await import("node:fs/promises")).chmod(unlockPath, 0o600); + const prior = process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; + process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = unlockPath; + try { + const report = createCompileReport({ + persistent_mounts: [{ + id: "daimon-agy-subscription-realm", + mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "Daimon host AGY subscription realm", + volume_name: "spawnfile-stable-agy-realm" + }], + runtime_instances: [{ + config_path: configPath, + engine_by_node_id: { "agent:agy": "agy" }, + home_path: null, + id: "daimon-organization", + runtime: "daimon" + }], + runtimes_installed: ["daimon"] + }); + const invocation = await createDockerRunInvocation({ + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory, + report, + reportPath: path.join(outputDirectory, "spawnfile-report.json") + }, "spawnfile-agy"); + expect(invocation.args).toContain("spawnfile-stable-agy-realm:/var/lib/spawnfile/daimon/agy-subscription-realm"); + expect(invocation.args).toContain(`${unlockPath}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro`); + expect(invocation.args.join("\n")).not.toContain("unlock-canary"); + expect(await readUtf8File(invocation.envFilePath)).not.toContain("unlock-canary"); + expect(JSON.stringify(report)).not.toContain(unlockPath); + await removeDirectory(invocation.supportDirectory); + } finally { + if (prior === undefined) delete process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; + else process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = prior; + } + }); + it("fails when required model auth is missing", async () => { await expect( createDockerRunInvocation( diff --git a/src/compiler/runProject.ts b/src/compiler/runProject.ts index e9478b23..4505b6b9 100644 --- a/src/compiler/runProject.ts +++ b/src/compiler/runProject.ts @@ -27,6 +27,7 @@ import { } from "../deployment/index.js"; import { DEFAULT_OUTPUT_DIRECTORY, SpawnfileError } from "../shared/index.js"; import { ensureNoopolisRunId, resolveNoopolisRunId } from "../runtime/index.js"; +import { DAIMON_AUTHORIZED_UID_ENV } from "./containerDaimonUidEntrypointRender.js"; import { compileProject, @@ -36,6 +37,7 @@ import { import { createDefaultImageTag, resolveDockerBuildArchitecture } from "./buildProject.js"; import { slugify } from "./helpers.js"; import { + inspectDetachedContainer as recoverDetachedDockerRun, runDockerContainer, type DockerRunInvocation, type DockerRunResult, @@ -53,7 +55,7 @@ import { resolveRunEnvironment } from "./runProjectAuth.js"; -export { runDockerContainer }; +export { recoverDetachedDockerRun, runDockerContainer }; export type { DockerRunInvocation, DockerRunResult, DockerRunRunner }; export interface RunProjectOptions extends CompileProjectOptions { @@ -138,6 +140,10 @@ export const createDockerRunInvocation = async ( ); assertRunEnvironmentSatisfied(containerReport, env, preparedRuntimeAuth.coveredModelSecrets); assertMoltnetCredentialValuesDistinct(containerReport, env); + const hasDaimon = containerReport.runtime_instances.some( + (instance) => instance.runtime === "daimon" + ); + const opaqueDaimonCredentials = preparedRuntimeAuth.launchIdentity?.kind === "daimon"; await ensureDirectory(supportDirectory); await writeUtf8File(envFilePath, renderDockerEnvFile(env)); @@ -162,6 +168,17 @@ export const createDockerRunInvocation = async ( args.push("--name", containerName); + if (hasDaimon) { + args.push( + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=SETUID", + "--cap-add=SETGID", + "--cap-add=DAC_READ_SEARCH", + "--security-opt=no-new-privileges:true" + ); + } + for (const port of containerReport.ports) { args.push("-p", `${port}:${port}`); } @@ -181,6 +198,9 @@ export const createDockerRunInvocation = async ( if (deploymentLabels) appendDockerLabelArgs(args, deploymentLabels); args.push("--env-file", envFilePath); + if (preparedRuntimeAuth.launchIdentity) { + args.push("--env", `${DAIMON_AUTHORIZED_UID_ENV}=${preparedRuntimeAuth.launchIdentity.uid}`); + } args.push(...(await resolveAuthMountArgs(containerReport, options.authProfile ?? null))); args.push(...preparedRuntimeAuth.mountArgs); args.push(imageTag); @@ -197,6 +217,7 @@ export const createDockerRunInvocation = async ( dockerHost: options.dockerHost ?? null, envFilePath, imageTag, + ...(opaqueDaimonCredentials ? { opaqueDaimonCredentials } : {}), supportDirectory }; } catch (error) { diff --git a/src/compiler/runProjectAuth.test.ts b/src/compiler/runProjectAuth.test.ts index 503b20d3..b3c1a8d6 100644 --- a/src/compiler/runProjectAuth.test.ts +++ b/src/compiler/runProjectAuth.test.ts @@ -180,14 +180,14 @@ describe("prepareRuntimeAuthMounts", () => { }); describe("resolveAuthMountArgs", () => { - it.each(["pi", "daimon"])("leaves %s runtime homes to the Pi auth adapter", async (runtime) => { + it("leaves the Pi runtime home to the Pi auth adapter", async () => { const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); process.env.SPAWNFILE_HOME = spawnfileHome; await registerImportedAuth("dev", "codex"); - const homePath = `/var/lib/spawnfile/instances/${runtime}/instance/home`; + const homePath = "/var/lib/spawnfile/instances/pi/instance/home"; await expect(resolveAuthMountArgs({ - ...createContainerReport(runtime), + ...createContainerReport("pi"), runtime_homes: [homePath] }, await requireAuthProfile("dev"))).resolves.toEqual([]); }); diff --git a/src/compiler/runProjectAuth.ts b/src/compiler/runProjectAuth.ts index 6f495c00..c62abe1d 100644 --- a/src/compiler/runProjectAuth.ts +++ b/src/compiler/runProjectAuth.ts @@ -11,6 +11,7 @@ import { import type { ContainerReport } from "../report/index.js"; import { SpawnfileError } from "../shared/index.js"; import { getRuntimeAdapter } from "../runtime/index.js"; +import type { RuntimeAuthPreparationResult } from "../runtime/types.js"; import { CLI_CREDENTIAL_SECRET_NAME, modelAuthMethodNeedsCliCredential @@ -18,6 +19,7 @@ import { interface PreparedRunAuth { coveredModelSecrets: Set; + launchIdentity?: RuntimeAuthPreparationResult["launchIdentity"]; mountArgs: string[]; } @@ -98,6 +100,7 @@ export const prepareRuntimeAuthMounts = async ( // responsible for treating a `null` `authProfile` as "no profile-derived // imports available" rather than failing. const coveredModelSecrets = new Set(); + let launchIdentity: RuntimeAuthPreparationResult["launchIdentity"]; const mountArgs: string[] = []; for (const instance of containerReport.runtime_instances) { @@ -115,10 +118,23 @@ export const prepareRuntimeAuthMounts = async ( }); addCoveredModelSecrets(coveredModelSecrets, instance.id, prepared.coveredModelSecrets); + if (prepared.launchIdentity) { + if (launchIdentity && launchIdentity.uid !== prepared.launchIdentity.uid) { + throw new SpawnfileError( + "validation_error", + "Opaque runtime credentials resolve conflicting launch UIDs" + ); + } + launchIdentity = prepared.launchIdentity; + } mountArgs.push(...prepared.mountArgs); } - return { coveredModelSecrets, mountArgs }; + return { + coveredModelSecrets, + ...(launchIdentity ? { launchIdentity } : {}), + mountArgs + }; }; export const assertDeclaredModelAuthSatisfied = ( @@ -180,6 +196,9 @@ const createGeneratedRuntimeSecret = ( if (generatedMoltnetSecrets.has(secretName)) { return randomBytes(32).toString("base64url"); } + if (secretName === "SPAWNFILE_DAIMON_CONTROL_TOKEN") { + return randomBytes(32).toString("base64url"); + } return null; }; @@ -350,7 +369,7 @@ export const resolveAuthMountArgs = async ( // duplicate Docker mount points and bypass the adapter's minimal-file staging. const piRuntimeHomes = new Set( containerReport.runtime_instances - .filter((instance) => instance.runtime === "pi" || instance.runtime === "daimon") + .filter((instance) => instance.runtime === "pi") .map((instance) => instance.home_path) .filter((homePath): homePath is string => homePath !== null) ); diff --git a/src/compiler/runProjectDocker.test.ts b/src/compiler/runProjectDocker.test.ts index bd2c6be9..40bb805b 100644 --- a/src/compiler/runProjectDocker.test.ts +++ b/src/compiler/runProjectDocker.test.ts @@ -10,11 +10,11 @@ const labels = { "com.spawnfile.unit": "football-container", "com.spawnfile.version": "0.1" }; const inspected = (overrides: { id?: string; labels?: unknown; extra?: string } = {}): string => - `${JSON.stringify(overrides.id ?? id)}\n${JSON.stringify(imageId)}\n${JSON.stringify(overrides.labels ?? labels)}${overrides.extra ?? ""}`; + `${JSON.stringify(overrides.id ?? id)}\n${JSON.stringify("/football")}\n${JSON.stringify(imageId)}\n${JSON.stringify(overrides.labels ?? labels)}${overrides.extra ?? ""}`; describe("detached Docker inspection", () => { it("returns only the verified full id, image id, and exact deployment labels", () => { - expect(parseDetachedContainerInspect(inspected(), id, labels)).toEqual({ containerId: id, imageId, deploymentLabels: labels }); + expect(parseDetachedContainerInspect(inspected(), id, labels, "football")).toEqual({ containerId: id, containerName: "football", imageId, deploymentLabels: labels }); }); it.each([ @@ -24,17 +24,17 @@ describe("detached Docker inspection", () => { ["extra line", inspected({ extra: "\n{}" })], ["malformed response", "not-json"] ])("rejects %s before finalization", (_label, stdout) => { - expect(() => parseDetachedContainerInspect(stdout, id, labels)).toThrow(); + expect(() => parseDetachedContainerInspect(stdout, id, labels, "football")).toThrow(); }); it.each(["sha256:short", `sha256:${"A".repeat(64)}`, `sha256:${"c".repeat(65)}`])("rejects a non-canonical image id before finalization", (badImage) => { - const stdout = `${JSON.stringify(id)}\n${JSON.stringify(badImage)}\n${JSON.stringify(labels)}`; - expect(() => parseDetachedContainerInspect(stdout, id, labels)).toThrow(); + const stdout = `${JSON.stringify(id)}\n${JSON.stringify("/football")}\n${JSON.stringify(badImage)}\n${JSON.stringify(labels)}`; + expect(() => parseDetachedContainerInspect(stdout, id, labels, "football")).toThrow(); }); it("uses the selected Docker context or host without name/list lookup", () => { const base = { args: [], command: "docker", containerName: "football", cwd: "/tmp", detach: true, envFilePath: "/tmp/run.env", imageTag: "football:latest", supportDirectory: "/tmp/support" }; - expect(createDetachedContainerInspectArgs({ ...base, dockerContext: "remote" }, id)).toEqual(["--context", "remote", "inspect", "--format", "{{json .Id}}\n{{json .Image}}\n{{json .Config.Labels}}", id]); - expect(createDetachedContainerInspectArgs({ ...base, dockerHost: "ssh://host" }, id)).toEqual(["--host", "ssh://host", "inspect", "--format", "{{json .Id}}\n{{json .Image}}\n{{json .Config.Labels}}", id]); + expect(createDetachedContainerInspectArgs({ ...base, dockerContext: "remote" }, id)).toEqual(["--context", "remote", "inspect", "--format", "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", id]); + expect(createDetachedContainerInspectArgs({ ...base, dockerHost: "ssh://host" }, id)).toEqual(["--host", "ssh://host", "inspect", "--format", "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", id]); }); }); diff --git a/src/compiler/runProjectDocker.ts b/src/compiler/runProjectDocker.ts index be5acd6d..05fd67ce 100644 --- a/src/compiler/runProjectDocker.ts +++ b/src/compiler/runProjectDocker.ts @@ -3,6 +3,10 @@ import path from "node:path"; import { promisify } from "node:util"; import { SpawnfileError } from "../shared/index.js"; +import { + assertOpaqueDaimonCredentialsHaveNoUserNamespace, + pinOpaqueDaimonDockerEndpoint +} from "./runProjectDockerDaimonGuards.js"; const execFile = promisify(execFileCallback); @@ -18,12 +22,15 @@ export interface DockerRunInvocation { dockerHost?: string | null; envFilePath: string; imageTag: string; - onDetachedStarted?: (result: { containerId: string }) => Promise; + onDetachedStarted?: (result: DockerRunResult) => Promise; + /** Ephemeral guard; never serialized into reports, records, or labels. */ + opaqueDaimonCredentials?: boolean; supportDirectory: string; } export interface DockerRunResult { containerId?: string; + containerName?: string; deploymentLabels?: Readonly>; imageId?: string; } @@ -41,6 +48,10 @@ interface PreparedRunInvocation { invocation: DockerRunInvocation; } +interface PinnedOpaqueDaimonInvocation extends DockerRunInvocation { + dockerEndpointPinned?: boolean; +} + const parseDockerContextHost = (stdout: string): string | null => { const trimmed = stdout.trim(); if (!trimmed) { @@ -216,30 +227,32 @@ export const createDetachedContainerInspectArgs = ( : invocation.dockerHost ? ["--host", invocation.dockerHost, "inspect"] : ["inspect"]; - return [...base, "--format", "{{json .Id}}\n{{json .Image}}\n{{json .Config.Labels}}", containerId]; + return [...base, "--format", "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", containerId]; }; export const parseDetachedContainerInspect = ( stdout: string, expectedContainerId: string, - expectedLabels?: Readonly> + expectedLabels?: Readonly>, + expectedContainerName?: string | null ): DockerRunResult => { - const [idRaw, imageRaw, labelsRaw, ...extra] = stdout.trim().split("\n"); - if (!idRaw || !imageRaw || !labelsRaw || extra.length > 0) throw new Error("unexpected inspect response"); - const actualId = JSON.parse(idRaw) as unknown; const imageId = JSON.parse(imageRaw) as unknown; const labels = JSON.parse(labelsRaw) as unknown; + const [idRaw, nameRaw, imageRaw, labelsRaw, ...extra] = stdout.trim().split("\n"); + if (!idRaw || !nameRaw || !imageRaw || !labelsRaw || extra.length > 0) throw new Error("unexpected inspect response"); + const actualId = JSON.parse(idRaw) as unknown; const actualName = JSON.parse(nameRaw) as unknown; const imageId = JSON.parse(imageRaw) as unknown; const labels = JSON.parse(labelsRaw) as unknown; if (typeof actualId !== "string" || actualId !== expectedContainerId || !/^[a-f0-9]{64}$/u.test(actualId) + || typeof actualName !== "string" || actualName !== `/${expectedContainerName ?? ""}` || typeof imageId !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(imageId)) throw new Error("malformed detached container metadata"); - if (!expectedLabels) return { containerId: actualId, imageId }; + if (!expectedLabels) return { containerId: actualId, containerName: actualName.slice(1), imageId }; if (!labels || typeof labels !== "object" || Array.isArray(labels)) throw new Error("missing detached deployment labels"); const actual = Object.fromEntries(Object.keys(expectedLabels).sort().map((key) => { const value = (labels as Record)[key]; if (typeof value !== "string" || value !== expectedLabels[key]) throw new Error("detached deployment label drift"); return [key, value]; })); - return { containerId: actualId, imageId, deploymentLabels: actual }; + return { containerId: actualId, containerName: actualName.slice(1), imageId, deploymentLabels: actual }; }; -const inspectDetachedContainer = async ( +export const inspectDetachedContainer = async ( invocation: DockerRunInvocation, containerId: string ): Promise => { @@ -248,7 +261,7 @@ const inspectDetachedContainer = async ( cwd: invocation.cwd, timeout: 10_000 }); - return parseDetachedContainerInspect(stdout, containerId, invocation.deploymentLabels); + return parseDetachedContainerInspect(stdout, containerId, invocation.deploymentLabels, invocation.containerName); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new SpawnfileError( @@ -298,10 +311,11 @@ const runPreparedDockerContainer = ( settle(() => resolve(undefined)); return; } - Promise.resolve( - prepared.invocation.onDetachedStarted?.({ containerId }) - ) - .then(() => inspectDetachedContainer(prepared.invocation, containerId)) + Promise.resolve(inspectDetachedContainer(prepared.invocation, containerId)) + .then(async (result) => { + await prepared.invocation.onDetachedStarted?.(result); + return result; + }) .then(resolve) .catch(reject); return; @@ -318,10 +332,18 @@ const runPreparedDockerContainer = ( }); }); -export const runDockerContainer: DockerRunRunner = ( - invocation: DockerRunInvocation +const runDockerContainerPrepared = ( + invocation: PinnedOpaqueDaimonInvocation ): Promise => { if (!invocation.dockerContext || collectBindMountSources(invocation.args).length === 0) { + if (invocation.opaqueDaimonCredentials && !invocation.dockerEndpointPinned) { + return assertOpaqueDaimonCredentialsHaveNoUserNamespace(invocation).then(() => + runPreparedDockerContainer({ + cleanup: async () => undefined, + invocation + }) + ); + } return runPreparedDockerContainer({ cleanup: async () => undefined, invocation @@ -330,3 +352,13 @@ export const runDockerContainer: DockerRunRunner = ( return prepareRemoteBindMounts(invocation).then(runPreparedDockerContainer); }; + +export const runDockerContainer: DockerRunRunner = (invocation) => + invocation.opaqueDaimonCredentials + ? pinOpaqueDaimonDockerEndpoint(invocation) + .then(async (pinned) => { + await assertOpaqueDaimonCredentialsHaveNoUserNamespace(pinned); + return pinned; + }) + .then((pinned) => runDockerContainerPrepared({ ...pinned, dockerEndpointPinned: true })) + : runDockerContainerPrepared(invocation); diff --git a/src/compiler/runProjectDockerDaimonGuards.ts b/src/compiler/runProjectDockerDaimonGuards.ts new file mode 100644 index 00000000..56d027b9 --- /dev/null +++ b/src/compiler/runProjectDockerDaimonGuards.ts @@ -0,0 +1,168 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; + +import { SpawnfileError } from "../shared/index.js"; + +const execFile = promisify(execFileCallback); + +export interface DaimonDockerGuardInvocation { + args: string[]; + command: string; + cwd: string; + dockerContext?: string | null; + dockerHost?: string | null; + opaqueDaimonCredentials?: boolean; +} + +const parseDockerContextHost = (stdout: string): string | null => { + const trimmed = stdout.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + return typeof parsed === "string" ? parsed : null; + } catch { + return trimmed; + } +}; + +const resolveDockerContextHost = async ( + invocation: DaimonDockerGuardInvocation, + context: string +): Promise => { + const { stdout } = await execFile(invocation.command, [ + "context", "inspect", context, "--format", "{{json .Endpoints.docker.Host}}" + ], { cwd: invocation.cwd, timeout: 10_000 }); + return parseDockerContextHost(stdout); +}; + +const resolveActiveDockerContext = async (invocation: DaimonDockerGuardInvocation): Promise => { + const { stdout } = await execFile(invocation.command, ["context", "show"], { + cwd: invocation.cwd, + timeout: 10_000 + }); + const context = stdout.trim(); + if (!context || /\s/u.test(context)) { + throw new SpawnfileError( + "validation_error", + "Unable to attest the active Docker context for opaque Daimon credentials" + ); + } + return context; +}; + +const resolveDockerDaemonEndpoint = async ( + invocation: DaimonDockerGuardInvocation +): Promise => { + if (invocation.dockerContext) return resolveDockerContextHost(invocation, invocation.dockerContext); + if (invocation.dockerHost) return invocation.dockerHost; + const ambientContext = process.env.DOCKER_CONTEXT?.trim(); + if (ambientContext) return resolveDockerContextHost(invocation, ambientContext); + const ambientHost = process.env.DOCKER_HOST?.trim(); + if (ambientHost) return ambientHost; + return resolveDockerContextHost(invocation, await resolveActiveDockerContext(invocation)); +}; + +const isLocalDockerEndpoint = (host: string): boolean => { + if (host.startsWith("/")) return true; + if (host.startsWith("unix://")) { + try { + const endpoint = new URL(host); + return endpoint.host === "" && endpoint.pathname.startsWith("/"); + } catch { + return false; + } + } + return host.startsWith("npipe:////./pipe/") || host.startsWith("npipe://./pipe/"); +}; + +const explicitLocalEndpoint = (endpoint: string): string => + endpoint.startsWith("/") ? `unix://${endpoint}` : endpoint; + +const withPinnedDockerEndpoint = ( + invocation: T, + endpoint: string +): T => { + const run = invocation.args.indexOf("run"); + if (run < 0) { + throw new SpawnfileError( + "validation_error", + "Unable to pin Docker daemon endpoint for opaque Daimon credentials" + ); + } + return { + ...invocation, + args: ["--host", endpoint, ...invocation.args.slice(run)], + dockerContext: null, + dockerHost: endpoint + } as T; +}; + +/** + * Resolves a possibly mutable Docker context once, validates that endpoint, + * then makes every remaining Docker operation use the immutable endpoint. + */ +export const pinOpaqueDaimonDockerEndpoint = async ( + invocation: T +): Promise => { + if (!invocation.opaqueDaimonCredentials) return invocation; + let resolved: string | null; + try { + resolved = await resolveDockerDaemonEndpoint(invocation); + } catch (error) { + if (error instanceof SpawnfileError) throw error; + throw new SpawnfileError( + "validation_error", + "Unable to attest the Docker daemon locality for opaque Daimon credentials" + ); + } + if (!resolved || !isLocalDockerEndpoint(resolved)) { + throw new SpawnfileError( + "validation_error", + "Opaque Daimon credentials require a local Docker daemon; remote and SSH daemons are unsupported" + ); + } + return withPinnedDockerEndpoint(invocation, explicitLocalEndpoint(resolved)); +}; + +export const assertOpaqueDaimonCredentialsUseLocalDaemon = async ( + invocation: DaimonDockerGuardInvocation +): Promise => { + await pinOpaqueDaimonDockerEndpoint(invocation); +}; + +export const assertOpaqueDaimonCredentialsHaveNoUserNamespace = async ( + invocation: DaimonDockerGuardInvocation +): Promise => { + if (!invocation.opaqueDaimonCredentials) return; + if (invocation.dockerContext || !invocation.dockerHost) { + throw new SpawnfileError( + "validation_error", + "Opaque Daimon credentials require a pinned Docker daemon endpoint before Docker info" + ); + } + const args = [ + "--host", invocation.dockerHost, + "info", "--format", "{{json .SecurityOptions}}" + ]; + let securityOptions: unknown; + try { + const { stdout } = await execFile(invocation.command, args, { + cwd: invocation.cwd, + timeout: 10_000 + }); + securityOptions = JSON.parse(stdout.trim()); + } catch { + throw new SpawnfileError( + "validation_error", + "Unable to verify that the Docker daemon has no user namespace remapping for opaque Daimon credentials" + ); + } + if (!Array.isArray(securityOptions) || securityOptions.some((value) => + typeof value !== "string" || /userns|rootless/iu.test(value) + )) { + throw new SpawnfileError( + "validation_error", + "Opaque Daimon credentials require a Docker daemon without user namespace remapping" + ); + } +}; diff --git a/src/compiler/runProjectLifecycle.ts b/src/compiler/runProjectLifecycle.ts index 96a3477c..c8966215 100644 --- a/src/compiler/runProjectLifecycle.ts +++ b/src/compiler/runProjectLifecycle.ts @@ -23,9 +23,9 @@ export const executeDockerRunWithSupportCleanup = async ( ? { ...invocation, onDetachedStarted: async (result) => { + await invocation.onDetachedStarted?.(result); started = true; await rm(invocation.envFilePath, { force: true }); - await invocation.onDetachedStarted?.(result); } } : invocation; diff --git a/src/compiler/upProject.test.ts b/src/compiler/upProject.test.ts index 041d415b..5faece4a 100644 --- a/src/compiler/upProject.test.ts +++ b/src/compiler/upProject.test.ts @@ -18,6 +18,7 @@ import type { ContainerReport, ContainerRuntimeInstanceReport } from "../report/index.js"; +import type { DockerRunInvocation } from "./runProject.js"; const temporaryDirectories: string[] = []; const previousCodexHome = process.env.CODEX_HOME; @@ -120,7 +121,7 @@ const loadUpProjectModule = async () => { _: unknown, imageTag: string, options: { containerName?: string | undefined } - ) => ({ + ): Promise => ({ args: ["run", "--rm", "--name", "spawnfile-up-container", "spawnfile-up-container"], command: "docker", containerName: options.containerName ?? "spawnfile-up-container", @@ -369,6 +370,50 @@ describe("upProject", () => { expect(result.supportDirectory).toBe("/tmp/spawnfile-run-support"); }); + it("publishes detached container authority before the Docker runner can create it", async () => { + const { upProject, createDockerRunInvocation } = await loadUpProjectModule(); + createDockerRunInvocation.mockResolvedValueOnce({ + args: ["run", "--detach", "spawnfile-up-container"], + command: "docker", + containerName: "detached-container", + deploymentLabels: { "dev.spawnfile.deployment": "default" }, + cwd: "/tmp/spawnfile-build-out", + detach: true, + deploymentName: null, + dockerContext: null, + envFilePath: "/tmp/spawnfile-run-support/run.env", + imageTag: "spawnfile-up-container", + supportDirectory: "/tmp/spawnfile-run-support", + }); + const events: string[] = []; + const onDetachedReservation = vi.fn(async (authority: { + containerName: string; + deploymentLabels: Readonly>; + dockerCommand: string; + dockerContext: string | null; + }) => { + events.push("reservation"); + expect(authority).toEqual({ + containerName: "detached-container", + deploymentLabels: { "dev.spawnfile.deployment": "default" }, + dockerCommand: "docker", + dockerContext: null, + }); + }); + const runRunner = vi.fn(async () => { events.push("docker-run"); }); + + await upProject("/tmp/project", { + detach: true, + imageTag: "spawnfile-up-container", + onDetachedReservation, + runRunner, + }); + + expect(events).toEqual(["reservation", "docker-run"]); + expect(onDetachedReservation).toHaveBeenCalledOnce(); + expect(runRunner).toHaveBeenCalledOnce(); + }); + it("writes a deployment record after a detached up succeeds", async () => { const outputDirectory = await createTempDirectory("spawnfile-up-out-"); const buildProject = vi.fn(async () => ({ diff --git a/src/compiler/upProject.ts b/src/compiler/upProject.ts index 2a06536e..046e4152 100644 --- a/src/compiler/upProject.ts +++ b/src/compiler/upProject.ts @@ -1,25 +1,18 @@ import path from "node:path"; -import { createHash } from "node:crypto"; import { rm } from "node:fs/promises"; import { createOrganizationReadinessPending, - createDockerOrganizationHandoffSession, createDockerDeploymentRecord, - initializeOrganizationHandoffAuthorityStore, - parseCanonicalSha256Digest, probeDockerOrganizationReadiness, readDeploymentRecord, resolveDockerDeploymentTarget, resolveDeploymentRecordPath, type DockerTargetExecFile, - type OrganizationHandoffCapabilityPending, - type OrganizationHandoffInput, writeDeploymentRecord, - writeDockerDeploymentRecordForRun + writeDockerDeploymentRecordForRun, } from "../deployment/index.js"; -import { createCanonicalSelectedTargetReceiptBytes, parseOpaqueTargetHandle, parseRunId, parseSelectedTargetReceipt, selectTarget, type OpaqueTargetHandle, type SelectedTargetReceipt } from "../target/index.js"; - +import type { UpLifecycleRecovery } from "../deployment/upLifecycleRecoveryState.js"; import { buildProject, type BuildProjectResult, @@ -39,7 +32,13 @@ import { DEFAULT_OUTPUT_DIRECTORY, SpawnfileError } from "../shared/index.js"; import { ensureNoopolisRunId, resolveNoopolisRunId } from "../runtime/index.js"; import { fileExists } from "../filesystem/index.js"; import { resolveHostCliCredential } from "./runProjectAuth.js"; - +import { + compileOrganizationHandoff, + executeOrganizationHandoff, + organizationHandoffInputError, + resolveRequestedOrganizationHandoff, + verifyRequestedOrganizationHandoffTarget, +} from "./upProjectHandoff.js"; export interface UpProjectOptions extends CompileProjectOptions { authProfile?: string; buildRunner?: DockerBuildRunner; @@ -51,7 +50,15 @@ export interface UpProjectOptions extends CompileProjectOptions { dockerHost?: string; envFilePath?: string; imageTag?: string; + lifecycleRecovery?: UpLifecycleRecovery; runRunner?: DockerRunRunner; + onDetachedReservation?: (authority: { + containerName: string; + deploymentLabels: Readonly>; + dockerCommand: string; + dockerContext: string | null; + }) => Promise; + onDetachedStarted?: (result: DockerRunResult & { containerId: string; containerName: string; imageId: string; deploymentLabels: Readonly> }) => Promise; networkAttachmentHandle?: string; organizationHandoffRunId?: string; descriptorDigest?: string; @@ -66,29 +73,6 @@ export interface UpProjectResult extends BuildProjectResult { deploymentRecordPath?: string | null; supportDirectory: string | null; } - -const handoffInputError = (): SpawnfileError => - new SpawnfileError( - "validation_error", - "Organization handoff requires authorized run id, selected target receipt, descriptor digest, selected target receipt digest, network attachment handle, and world bindings" - ); - -const handoffRecoveryIncompleteError = (): SpawnfileError => - new SpawnfileError( - "runtime_error", - "Organization handoff recovery is incomplete; redeploy with explicit authorization" - ); - -interface RequestedHandoff extends Omit { - descriptorDigest: string; - runId: string; - selectedTarget: SelectedTargetReceipt; -} -interface CompiledHandoff { - authority: RequestedHandoff & { bindingDigest: string }; - organizationHandoff: OrganizationHandoffInput; -} - const stableRecord = (record: Awaited>) => { const { created_at: _createdAt, export_index: _exportIndex, organization_ready: _organizationReady, ...stable } = record; return stable; @@ -100,89 +84,20 @@ const canonicalJson = (value: unknown): string => { return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; } const serialized = JSON.stringify(value); - if (serialized === undefined) throw handoffInputError(); + if (serialized === undefined) throw organizationHandoffInputError(); return serialized; }; -const resolveRequestedHandoff = ( - options: UpProjectOptions -): RequestedHandoff | null => { - const values = [ - options.descriptorDigest, - options.organizationHandoffRunId, - options.selectedTargetReceiptDigest, - options.selectedTargetReceipt, - options.networkAttachmentHandle, - options.worldBindingsPath - ]; - if (values.every((value) => value === undefined)) return null; - if (values.some((value) => value === undefined) || !options.detach) throw handoffInputError(); - - try { - const selectedTarget = parseSelectedTargetReceipt(options.selectedTargetReceipt); - const selectedDigest = parseCanonicalSha256Digest(options.selectedTargetReceiptDigest, "selected_target_receipt_digest"); - const computedDigest = `sha256:${createHash("sha256").update(createCanonicalSelectedTargetReceiptBytes(selectedTarget), "utf8").digest("hex")}`; - if (selectedDigest !== computedDigest) throw new Error("selected receipt digest mismatch"); - return { - descriptorDigest: parseCanonicalSha256Digest(options.descriptorDigest, "descriptor_digest"), - runId: parseRunId(options.organizationHandoffRunId), - networkAttachmentHandle: parseOpaqueTargetHandle(options.networkAttachmentHandle), - selectedTarget, - selectedTargetReceiptDigest: selectedDigest - }; - } catch { - throw handoffInputError(); - } -}; - -const verifyRequestedHandoffTarget = async ( - requested: RequestedHandoff | null, - resolved: { readonly dockerContext?: string | null; readonly dockerHost?: string | null }, - options: Pick -): Promise => { - if (!requested) return; - if (typeof resolved.dockerContext !== "string" || resolved.dockerContext.length === 0 - || resolved.dockerHost != null || process.env.DOCKER_HOST != null) throw handoffInputError(); - try { - const actual = await selectTarget({ - context: resolved.dockerContext, - dockerCommand: options.dockerCommand, - execFile: options.targetExecFile - }); - if (createCanonicalSelectedTargetReceiptBytes(actual) - !== createCanonicalSelectedTargetReceiptBytes(requested.selectedTarget)) throw new Error("target mismatch"); - } catch { - throw handoffInputError(); - } -}; - -const resolveCompiledHandoff = ( - requested: RequestedHandoff | null, - buildResult: BuildProjectResult -): CompiledHandoff | undefined => { - if (!requested) return undefined; - try { - const bindingDigest = buildResult.organizationReadinessEvidence.worldBindings?.digest; - if (!bindingDigest) throw new Error("missing compiled binding evidence"); - const bindingDigestParsed = parseCanonicalSha256Digest(bindingDigest, "binding_digest"); - return { authority: { ...requested, bindingDigest: bindingDigestParsed }, organizationHandoff: { - bindingDigest: bindingDigestParsed, networkAttachmentHandle: requested.networkAttachmentHandle, - selectedTargetReceiptDigest: requested.selectedTargetReceiptDigest } }; - } catch { - throw handoffInputError(); - } -}; - export const upProject = async ( inputPath: string, options: UpProjectOptions = {} ): Promise => { // Parse caller authority before any target or build work. Target identity is // verified below from the resolved detached options, including exact record reuse. - const requestedHandoff = resolveRequestedHandoff(options); + const requestedHandoff = resolveRequestedOrganizationHandoff(options); // Handoff reservation identity must survive a process crash. Unlike an // ordinary deployment run, it may never silently mint a fresh run id. - if (requestedHandoff && resolveNoopolisRunId(process.env) !== requestedHandoff.runId) throw handoffInputError(); + if (requestedHandoff && resolveNoopolisRunId(process.env) !== requestedHandoff.runId) throw organizationHandoffInputError(); const resolvedOptions = await resolveDetachedDeploymentOptions( path.resolve(options.outputDirectory ?? DEFAULT_OUTPUT_DIRECTORY), { @@ -198,7 +113,7 @@ export const upProject = async ( targetExecFile: options.targetExecFile } ); - await verifyRequestedHandoffTarget(requestedHandoff, resolvedOptions, options); + await verifyRequestedOrganizationHandoffTarget(requestedHandoff, resolvedOptions, options); // Every authority container compiled for this deployment must stamp // causal events under the same real run id (see specs/CAUSAL.md). // buildProject/compileProject stay deterministic functions of the host @@ -219,7 +134,7 @@ export const upProject = async ( ? { worldBindingsPath: options.worldBindingsPath } : {}) }); - const handoff = resolveCompiledHandoff(requestedHandoff, buildResult); + const handoff = compileOrganizationHandoff(requestedHandoff, buildResult); const authProfile = resolvedOptions.authProfile ? await requireAuthProfile(resolvedOptions.authProfile) : null; @@ -236,110 +151,111 @@ export const upProject = async ( dockerHost: resolvedOptions.dockerHost, envFilePath: resolvedOptions.envFilePath }); + if (options.onDetachedStarted) { + const started = options.onDetachedStarted; + invocation.onDetachedStarted = async (result) => { + if (!result.containerId || !result.containerName || !result.imageId || !result.deploymentLabels) { + throw new SpawnfileError("runtime_error", "Detached deployment metadata is incomplete"); + } + await started({ ...result, containerId: result.containerId, containerName: result.containerName, imageId: result.imageId, deploymentLabels: result.deploymentLabels }); + }; + } - let authority: Awaited> | undefined; let deploymentRecordPath: string | null; - let recovered = false; - let dockerLifecycleInvoked = false; - try { - let pending: OrganizationHandoffCapabilityPending | undefined; - if (handoff) { - if (!invocation.deploymentLabels || !invocation.containerName) throw handoffInputError(); - const session = createDockerOrganizationHandoffSession({ - bindingDigest: handoff.authority.bindingDigest, + if (handoff) { + const executed = await executeOrganizationHandoff({ + dockerCommand: options.dockerCommand, + handoff, + invocation, + lifecycleRecovery: options.lifecycleRecovery, + onDetachedReservation: options.onDetachedReservation, + runRunner: options.runRunner ?? runDockerContainer, + targetExecFile: options.targetExecFile, + }); + const { organizationHandoffHandle, runMetadata } = executed; + const existingRecordPath = invocation.detach && invocation.deploymentName + ? resolveDeploymentRecordPath(buildResult.outputDirectory, invocation.deploymentName) + : null; + if (existingRecordPath && await fileExists(existingRecordPath)) { + const existing = await readDeploymentRecord(existingRecordPath); + const target = await resolveDockerDeploymentTarget({ + context: invocation.dockerContext ?? undefined, + dockerCommand: invocation.command, + dockerHost: invocation.dockerHost ?? undefined, + execFile: options.targetExecFile, + }); + const expected = createDockerDeploymentRecord({ + authProfileName: authProfile?.name ?? null, + compileFingerprint: buildResult.report.compile_fingerprint ?? "", containerName: invocation.containerName, - deploymentLabels: invocation.deploymentLabels, - descriptorDigest: handoff.authority.descriptorDigest, + deploymentName: invocation.deploymentName ?? undefined, + envFilePath: resolvedOptions.envFilePath, + imageTag, + networkIds: buildResult.report.container?.moltnet?.server_plans + .filter((server) => server.mode === "managed").map((server) => server.network_id), + nodes: buildResult.report.nodes, organizationHandoff: handoff.organizationHandoff, + organizationHandoffHandle, + outputDirectory: buildResult.outputDirectory, + projectRoot: buildResult.report.root, runId: handoff.authority.runId, - selectedTarget: handoff.authority.selectedTarget, - selectedTargetReceiptDigest: handoff.authority.selectedTargetReceiptDigest - }); - authority = await initializeOrganizationHandoffAuthorityStore(); - const begun = await authority.begin(session.authorityInput); - pending = begun.pending; - recovered = !begun.created; - } - let runMetadata: DockerRunResult | void = undefined; - if (!recovered) { - dockerLifecycleInvoked = true; - runMetadata = await executeDockerRunWithSupportCleanup(invocation, options.runRunner ?? runDockerContainer); - } - const observed = recovered && authority && pending - ? await authority.readDockerMutation(pending.pending_key) - : undefined; - if (recovered && !observed) throw handoffRecoveryIncompleteError(); - const recoveredMetadata: DockerRunResult | void = observed - ? { containerId: observed.container_id, deploymentLabels: observed.deployment_labels, imageId: observed.image_id } - : undefined; - const exactRunMetadata = runMetadata ?? recoveredMetadata; - let organizationHandoffHandle: OpaqueTargetHandle | undefined; - if (handoff) { - if (!exactRunMetadata?.containerId || !exactRunMetadata.deploymentLabels || !exactRunMetadata.imageId || !authority || !pending) throw handoffInputError(); - if (!recovered) await authority.observeDockerMutation(pending.pending_key, { - containerId: exactRunMetadata.containerId, deploymentLabels: exactRunMetadata.deploymentLabels, imageId: exactRunMetadata.imageId + runMetadata, + runtimeInstanceIds: buildResult.report.container?.runtime_instances.map((instance) => instance.id) ?? [], + target, }); - const finalized = await authority.finalize(pending.pending_key, { containerId: exactRunMetadata.containerId, deploymentLabels: exactRunMetadata.deploymentLabels }); - organizationHandoffHandle = finalized.organization_handoff_handle; - const existingRecordPath = invocation.detach && invocation.deploymentName - ? resolveDeploymentRecordPath(buildResult.outputDirectory, invocation.deploymentName) + if (canonicalJson(stableRecord(existing)) !== canonicalJson(stableRecord(expected))) { + throw organizationHandoffInputError(); + } + deploymentRecordPath = existingRecordPath; + } else { + deploymentRecordPath = invocation.detach && invocation.deploymentName + ? await writeDockerDeploymentRecordForRun({ + authProfileName: authProfile?.name ?? null, + envFilePath: resolvedOptions.envFilePath, + imageTag, + invocation, + organizationHandoff: handoff.organizationHandoff, + organizationHandoffHandle, + outputDirectory: buildResult.outputDirectory, + report: buildResult.report, + runMetadata, + targetExecFile: options.targetExecFile, + }) : null; - if (existingRecordPath && await fileExists(existingRecordPath)) { - const existing = await readDeploymentRecord(existingRecordPath); - const target = await resolveDockerDeploymentTarget({ - context: invocation.dockerContext ?? undefined, - dockerCommand: invocation.command, - dockerHost: invocation.dockerHost ?? undefined, - execFile: options.targetExecFile - }); - const expected = createDockerDeploymentRecord({ - authProfileName: authProfile?.name ?? null, - compileFingerprint: buildResult.report.compile_fingerprint ?? "", + } + } else { + let dockerLifecycleInvoked = false; + try { + if (options.onDetachedReservation) { + if (!invocation.detach || !invocation.containerName || !invocation.deploymentLabels) { + throw new SpawnfileError("runtime_error", "Detached deployment authority is incomplete"); + } + await options.onDetachedReservation({ containerName: invocation.containerName, - deploymentName: invocation.deploymentName ?? undefined, - envFilePath: resolvedOptions.envFilePath, - imageTag, - networkIds: buildResult.report.container?.moltnet?.server_plans - .filter((server) => server.mode === "managed").map((server) => server.network_id), - nodes: buildResult.report.nodes, - organizationHandoff: handoff.organizationHandoff, - organizationHandoffHandle, - outputDirectory: buildResult.outputDirectory, - projectRoot: buildResult.report.root, - runId: handoff.authority.runId, - runMetadata: exactRunMetadata, - runtimeInstanceIds: buildResult.report.container?.runtime_instances.map((instance) => instance.id) ?? [], - target + deploymentLabels: invocation.deploymentLabels, + dockerCommand: invocation.command, + dockerContext: invocation.dockerContext ?? null, }); - if (canonicalJson(stableRecord(existing)) !== canonicalJson(stableRecord(expected))) throw handoffInputError(); - deploymentRecordPath = existingRecordPath; - } else deploymentRecordPath = invocation.detach && invocation.deploymentName - ? await writeDockerDeploymentRecordForRun({ authProfileName: authProfile?.name ?? null, envFilePath: resolvedOptions.envFilePath, - imageTag, invocation, outputDirectory: buildResult.outputDirectory, - organizationHandoff: handoff.organizationHandoff, organizationHandoffHandle, report: buildResult.report, - runMetadata: exactRunMetadata, targetExecFile: options.targetExecFile }) + } + dockerLifecycleInvoked = true; + const runMetadata = await executeDockerRunWithSupportCleanup(invocation, options.runRunner ?? runDockerContainer); + deploymentRecordPath = invocation.detach && invocation.deploymentName + ? await writeDockerDeploymentRecordForRun({ + authProfileName: authProfile?.name ?? null, + envFilePath: resolvedOptions.envFilePath, + imageTag, + invocation, + outputDirectory: buildResult.outputDirectory, + report: buildResult.report, + runMetadata: runMetadata ?? undefined, + targetExecFile: options.targetExecFile, + }) : null; - } else deploymentRecordPath = invocation.detach && invocation.deploymentName - ? await writeDockerDeploymentRecordForRun({ authProfileName: authProfile?.name ?? null, envFilePath: resolvedOptions.envFilePath, - imageTag, invocation, outputDirectory: buildResult.outputDirectory, report: buildResult.report, - runMetadata: runMetadata ?? undefined, targetExecFile: options.targetExecFile }) - : null; - } finally { - // Replay creates a fresh support directory but does not invoke the normal - // Docker lifecycle wrapper. Preserve its detached credential cleanup - // semantics without deleting bind-mount support files. - let cleanupError: unknown; - try { - if (invocation.detach && !dockerLifecycleInvoked) await rm(invocation.envFilePath, { force: true }); - } catch (error) { - cleanupError = error; - } - try { - await authority?.dispose(); - } catch (error) { - if (cleanupError === undefined) throw error; + } finally { + if (invocation.detach && !dockerLifecycleInvoked) { + await rm(invocation.envFilePath, { force: true }); + } } - if (cleanupError !== undefined) throw cleanupError; } if (deploymentRecordPath && buildResult.organizationReadinessEvidence) { diff --git a/src/compiler/upProjectHandoff.ts b/src/compiler/upProjectHandoff.ts new file mode 100644 index 00000000..c1692d5b --- /dev/null +++ b/src/compiler/upProjectHandoff.ts @@ -0,0 +1,285 @@ +import { createHash } from "node:crypto"; +import { rm } from "node:fs/promises"; + +import { + createDockerOrganizationHandoffSession, + initializeOrganizationHandoffAuthorityStore, + parseCanonicalSha256Digest, + type DockerTargetExecFile, + type OrganizationHandoffInput, +} from "../deployment/index.js"; +import { + createCanonicalSelectedTargetReceiptBytes, + parseOpaqueTargetHandle, + parseRunId, + parseSelectedTargetReceipt, + selectTarget, + type OpaqueTargetHandle, + type SelectedTargetReceipt, +} from "../target/index.js"; +import type { UpLifecycleRecovery } from "../deployment/upLifecycleRecoveryState.js"; +import { isTrustedUpLifecycleRecovery } from "../deployment/upLifecycleRecoveryState.js"; +import { SpawnfileError } from "../shared/index.js"; + +import type { BuildProjectResult } from "./buildProject.js"; +import { executeDockerRunWithSupportCleanup } from "./runProjectLifecycle.js"; +import { + recoverDetachedDockerRun, + type DockerRunInvocation, + type DockerRunResult, + type DockerRunRunner, +} from "./runProject.js"; + +export interface OrganizationHandoffRequestOptions { + readonly descriptorDigest?: string; + readonly detach?: boolean; + readonly networkAttachmentHandle?: string; + readonly organizationHandoffRunId?: string; + readonly selectedTargetReceipt?: unknown; + readonly selectedTargetReceiptDigest?: string; + readonly worldBindingsPath?: string; +} + +export interface RequestedOrganizationHandoff extends Omit { + readonly descriptorDigest: string; + readonly runId: string; + readonly selectedTarget: SelectedTargetReceipt; +} + +export interface CompiledOrganizationHandoff { + readonly authority: RequestedOrganizationHandoff & { readonly bindingDigest: string }; + readonly organizationHandoff: OrganizationHandoffInput; +} + +export interface DetachedReservation { + readonly containerName: string; + readonly deploymentLabels: Readonly>; + readonly dockerCommand: string; + readonly dockerContext: string | null; +} + +export const organizationHandoffInputError = (): SpawnfileError => + new SpawnfileError( + "validation_error", + "Organization handoff requires authorized run id, selected target receipt, descriptor digest, selected target receipt digest, network attachment handle, and world bindings", + ); + +const recoveryError = (): SpawnfileError => new SpawnfileError( + "runtime_error", + "Organization handoff recovery is incomplete; redeploy with explicit authorization", +); + +export const resolveRequestedOrganizationHandoff = ( + options: OrganizationHandoffRequestOptions, +): RequestedOrganizationHandoff | null => { + const values = [ + options.descriptorDigest, + options.organizationHandoffRunId, + options.selectedTargetReceiptDigest, + options.selectedTargetReceipt, + options.networkAttachmentHandle, + options.worldBindingsPath, + ]; + if (values.every((value) => value === undefined)) return null; + if (values.some((value) => value === undefined) || !options.detach) throw organizationHandoffInputError(); + try { + const selectedTarget = parseSelectedTargetReceipt(options.selectedTargetReceipt); + const selectedDigest = parseCanonicalSha256Digest(options.selectedTargetReceiptDigest, "selected_target_receipt_digest"); + const computedDigest = `sha256:${createHash("sha256").update( + createCanonicalSelectedTargetReceiptBytes(selectedTarget), "utf8", + ).digest("hex")}`; + if (selectedDigest !== computedDigest) throw new Error("selected receipt digest mismatch"); + return { + descriptorDigest: parseCanonicalSha256Digest(options.descriptorDigest, "descriptor_digest"), + runId: parseRunId(options.organizationHandoffRunId), + networkAttachmentHandle: parseOpaqueTargetHandle(options.networkAttachmentHandle), + selectedTarget, + selectedTargetReceiptDigest: selectedDigest, + }; + } catch { + throw organizationHandoffInputError(); + } +}; + +export const verifyRequestedOrganizationHandoffTarget = async ( + requested: RequestedOrganizationHandoff | null, + resolved: { readonly dockerContext?: string | null; readonly dockerHost?: string | null }, + options: Pick, +): Promise => { + if (!requested) return; + if (typeof resolved.dockerContext !== "string" || resolved.dockerContext.length === 0 + || resolved.dockerHost != null || process.env.DOCKER_HOST != null) throw organizationHandoffInputError(); + try { + const actual = await selectTarget({ + context: resolved.dockerContext, + dockerCommand: options.dockerCommand, + execFile: options.targetExecFile, + }); + if (createCanonicalSelectedTargetReceiptBytes(actual) + !== createCanonicalSelectedTargetReceiptBytes(requested.selectedTarget)) throw new Error("target mismatch"); + } catch { + throw organizationHandoffInputError(); + } +}; + +export const compileOrganizationHandoff = ( + requested: RequestedOrganizationHandoff | null, + buildResult: BuildProjectResult, +): CompiledOrganizationHandoff | undefined => { + if (!requested) return undefined; + try { + const bindingDigest = buildResult.organizationReadinessEvidence.worldBindings?.digest; + if (!bindingDigest) throw new Error("missing compiled binding evidence"); + const parsedBindingDigest = parseCanonicalSha256Digest(bindingDigest, "binding_digest"); + return { + authority: { ...requested, bindingDigest: parsedBindingDigest }, + organizationHandoff: { + bindingDigest: parsedBindingDigest, + networkAttachmentHandle: requested.networkAttachmentHandle, + selectedTargetReceiptDigest: requested.selectedTargetReceiptDigest, + }, + }; + } catch { + throw organizationHandoffInputError(); + } +}; + +interface OrganizationHandoffExecutionOptions { + readonly dockerCommand?: string; + readonly lifecycleRecovery?: UpLifecycleRecovery; + readonly targetExecFile?: DockerTargetExecFile; +} + +export interface ExecuteOrganizationHandoffOptions extends OrganizationHandoffExecutionOptions { + readonly handoff: CompiledOrganizationHandoff; + readonly invocation: DockerRunInvocation; + readonly onDetachedReservation?: (authority: DetachedReservation) => Promise; + readonly runRunner: DockerRunRunner; +} + +interface ExactRunMetadata extends DockerRunResult { + readonly containerId: string; + readonly containerName: string; + readonly deploymentLabels: Readonly>; + readonly imageId: string; +} + +const canonical = (value: unknown): string => value === null || typeof value !== "object" + ? JSON.stringify(value) + : Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : `{${Object.keys(value as Record).sort().map((key) => + `${JSON.stringify(key)}:${canonical((value as Record)[key])}`).join(",")}}`; +const same = (left: unknown, right: unknown): boolean => canonical(left) === canonical(right); +const exactRunMetadata = (value: DockerRunResult | void): ExactRunMetadata => { + if (!value?.containerId || !value.containerName || !value.imageId || !value.deploymentLabels) { + throw organizationHandoffInputError(); + } + return value as ExactRunMetadata; +}; + +const reattestRecoveredRun = async ( + invocation: DockerRunInvocation, + expected: ExactRunMetadata, +): Promise => { + const actual = exactRunMetadata(await recoverDetachedDockerRun(invocation, expected.containerId)); + if (actual.containerName !== expected.containerName || actual.imageId !== expected.imageId + || !same(actual.deploymentLabels, expected.deploymentLabels)) throw recoveryError(); + return actual; +}; + +const recovery = (value: UpLifecycleRecovery | undefined): UpLifecycleRecovery | undefined => { + if (value !== undefined && !isTrustedUpLifecycleRecovery(value)) throw organizationHandoffInputError(); + return value; +}; + +const reserveDetachedRun = async ( + invocation: DockerRunInvocation, + reserve: ExecuteOrganizationHandoffOptions["onDetachedReservation"], +): Promise => { + if (!reserve) return; + if (!invocation.detach || !invocation.containerName || !invocation.deploymentLabels) { + throw new SpawnfileError("runtime_error", "Detached deployment authority is incomplete"); + } + await reserve({ + containerName: invocation.containerName, + deploymentLabels: invocation.deploymentLabels, + dockerCommand: invocation.command, + dockerContext: invocation.dockerContext ?? null, + }); +}; + +/** Resumes only lifecycle-verified state, never stale authority observations. */ +export const executeOrganizationHandoff = async ( + input: ExecuteOrganizationHandoffOptions, +): Promise<{ readonly organizationHandoffHandle: OpaqueTargetHandle; readonly runMetadata: ExactRunMetadata }> => { + const { handoff, invocation } = input; + if (!invocation.deploymentLabels || !invocation.containerName) throw organizationHandoffInputError(); + const lifecycleRecovery = recovery(input.lifecycleRecovery); + const session = createDockerOrganizationHandoffSession({ + bindingDigest: handoff.authority.bindingDigest, + containerName: invocation.containerName, + deploymentLabels: invocation.deploymentLabels, + descriptorDigest: handoff.authority.descriptorDigest, + organizationHandoff: handoff.organizationHandoff, + runId: handoff.authority.runId, + selectedTarget: handoff.authority.selectedTarget, + selectedTargetReceiptDigest: handoff.authority.selectedTargetReceiptDigest, + }); + let authority: Awaited> | undefined; + let dockerLifecycleInvoked = false; + try { + // Persist the lifecycle reservation first. A post-begin crash can then be + // distinguished as no-start, exact-container, or ambiguous on restart. + if (lifecycleRecovery?.kind !== "deployment_record") { + await reserveDetachedRun(invocation, input.onDetachedReservation); + } + authority = await initializeOrganizationHandoffAuthorityStore(); + const begun = await authority.begin(session.authorityInput); + let runMetadata: ExactRunMetadata; + if (begun.created) { + if (lifecycleRecovery?.kind === "detached_container" + || lifecycleRecovery?.kind === "deployment_record") throw recoveryError(); + dockerLifecycleInvoked = true; + runMetadata = exactRunMetadata(await executeDockerRunWithSupportCleanup(invocation, input.runRunner)); + } else { + const observed = await authority.readDockerMutation(begun.pending.pending_key); + if (observed) { + if (lifecycleRecovery?.kind === "no_docker_mutation") throw recoveryError(); + const expected: ExactRunMetadata = { + containerId: observed.container_id, + containerName: invocation.containerName, + deploymentLabels: observed.deployment_labels, + imageId: observed.image_id, + }; + if (lifecycleRecovery?.kind === "detached_container" && ( + lifecycleRecovery.containerId !== expected.containerId + || lifecycleRecovery.containerName !== expected.containerName + || lifecycleRecovery.imageId !== expected.imageId + || !same(lifecycleRecovery.deploymentLabels, expected.deploymentLabels) + )) throw recoveryError(); + runMetadata = await reattestRecoveredRun(invocation, expected); + } else if (lifecycleRecovery?.kind === "detached_container") { + runMetadata = await reattestRecoveredRun(invocation, lifecycleRecovery); + } else if (lifecycleRecovery?.kind === "no_docker_mutation") { + dockerLifecycleInvoked = true; + runMetadata = exactRunMetadata(await executeDockerRunWithSupportCleanup(invocation, input.runRunner)); + } else throw recoveryError(); + } + await authority.observeDockerMutation(begun.pending.pending_key, runMetadata); + const finalized = await authority.finalize(begun.pending.pending_key, runMetadata); + return { organizationHandoffHandle: finalized.organization_handoff_handle, runMetadata }; + } finally { + let cleanupError: unknown; + try { + if (invocation.detach && !dockerLifecycleInvoked) await rm(invocation.envFilePath, { force: true }); + } catch (error) { + cleanupError = error; + } + try { + await authority?.dispose(); + } catch (error) { + if (cleanupError === undefined) throw error; + } + if (cleanupError !== undefined) throw cleanupError; + } +}; diff --git a/src/compiler/upProjectOrganizationHandoff.test.ts b/src/compiler/upProjectOrganizationHandoff.test.ts index 062fd5fc..9d35f053 100644 --- a/src/compiler/upProjectOrganizationHandoff.test.ts +++ b/src/compiler/upProjectOrganizationHandoff.test.ts @@ -6,7 +6,8 @@ vi.mock("./buildProject.js", async (importOriginal) => ({ })); vi.mock("./runProject.js", async (importOriginal) => ({ ...await importOriginal(), - createDockerRunInvocation: vi.fn(), resolveDetachedDeploymentOptions: vi.fn(), runDockerContainer: vi.fn() + createDockerRunInvocation: vi.fn(), recoverDetachedDockerRun: vi.fn(), + resolveDetachedDeploymentOptions: vi.fn(), runDockerContainer: vi.fn() })); vi.mock("../deployment/index.js", async (importOriginal) => ({ ...await importOriginal(), @@ -21,7 +22,11 @@ vi.mock("node:fs/promises", async (importOriginal) => ({ })); import { buildProject, type BuildProjectResult } from "./buildProject.js"; -import { createDockerRunInvocation, resolveDetachedDeploymentOptions } from "./runProject.js"; +import { + createDockerRunInvocation, + recoverDetachedDockerRun, + resolveDetachedDeploymentOptions, +} from "./runProject.js"; import { upProject } from "./upProject.js"; import { probeDockerOrganizationReadiness, readDeploymentRecord, writeDeploymentRecord, @@ -30,6 +35,10 @@ import { } from "../deployment/index.js"; import { createCanonicalSelectedTargetReceiptBytes, createEndpointFingerprint, parseOpaqueTargetHandle } from "../target/index.js"; import { fileExists } from "../filesystem/index.js"; +import { + detachedContainerRecovery, + noDockerMutationRecovery, +} from "../deployment/upLifecycleRecoveryState.js"; import { rm } from "node:fs/promises"; import type { OrganizationReadinessEvidence } from "./organizationReadyEvidence.js"; @@ -55,7 +64,12 @@ const deploymentLabels = { "com.spawnfile.project": "football", "com.spawnfile.run_id": "run-from-host", "com.spawnfile.unit": "football-container", "com.spawnfile.version": "0.1" }; -const runMetadata = { containerId: "1".repeat(64), deploymentLabels, imageId: `sha256:${"f".repeat(64)}` }; +const runMetadata = { + containerId: "1".repeat(64), + containerName: "football", + deploymentLabels, + imageId: `sha256:${"f".repeat(64)}`, +}; const beginAuthority = vi.fn(); const observeAuthority = vi.fn(); const finalizeAuthority = vi.fn(); const readMutationAuthority = vi.fn(); const disposeAuthority = vi.fn(); const targetExecFile = vi.fn(async () => ({ stderr: "", stdout: JSON.stringify(endpoint) })); @@ -103,6 +117,7 @@ beforeEach(() => { beginAuthority.mockResolvedValue({ created: true, pending: { pending_key: "a".repeat(64) } }); observeAuthority.mockResolvedValue(undefined); readMutationAuthority.mockResolvedValue(null); + vi.mocked(recoverDetachedDockerRun).mockResolvedValue(runMetadata); finalizeAuthority.mockResolvedValue({ organization_handoff_handle: "opaque_ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }); disposeAuthority.mockResolvedValue(undefined); vi.mocked(initializeOrganizationHandoffAuthorityStore).mockResolvedValue({ @@ -220,6 +235,69 @@ describe("upProject organization handoff", () => { expect(writeDockerDeploymentRecordForRun).not.toHaveBeenCalled(); }); + it("restarts a pending handoff only after lifecycle proves Docker did not start", async () => { + vi.mocked(buildProject).mockResolvedValue(buildResult()); + vi.mocked(createDockerRunInvocation).mockResolvedValue({ + args: [], command: "docker", containerName: "football", cwd: "/tmp/spawnfile-handoff", detach: true, + deploymentName: "football", deploymentLabels, dockerContext: null, dockerHost: null, envFilePath: "/tmp/spawnfile-handoff.env", + imageTag: "football:latest", supportDirectory: "/tmp/spawnfile-handoff-support", + }); + beginAuthority.mockResolvedValue({ created: false, pending: { pending_key: "a".repeat(64) } }); + const reserve = vi.fn(); const run = vi.fn(async () => runMetadata); + await upProject("/tmp/project", { + ...complete, + lifecycleRecovery: noDockerMutationRecovery(), + onDetachedReservation: reserve, + runRunner: run, + }); + expect(reserve.mock.invocationCallOrder[0]).toBeLessThan(beginAuthority.mock.invocationCallOrder[0]!); + expect(beginAuthority.mock.invocationCallOrder[0]).toBeLessThan(run.mock.invocationCallOrder[0]!); + expect(observeAuthority).toHaveBeenCalledWith("a".repeat(64), expect.objectContaining(runMetadata)); + }); + + it("does not turn a stale observed handoff into a record after a no-mutation recovery", async () => { + vi.mocked(buildProject).mockResolvedValue(buildResult()); + vi.mocked(createDockerRunInvocation).mockResolvedValue({ + args: [], command: "docker", containerName: "football", cwd: "/tmp/spawnfile-handoff", detach: true, + deploymentName: "football", deploymentLabels, dockerContext: null, dockerHost: null, envFilePath: "/tmp/spawnfile-handoff.env", + imageTag: "football:latest", supportDirectory: "/tmp/spawnfile-handoff-support", + }); + beginAuthority.mockResolvedValue({ created: false, pending: { pending_key: "a".repeat(64) } }); + readMutationAuthority.mockResolvedValue({ + container_id: runMetadata.containerId, + deployment_labels: deploymentLabels, + image_id: runMetadata.imageId, + }); + const run = vi.fn(async () => runMetadata); + await expect(upProject("/tmp/project", { + ...complete, + lifecycleRecovery: noDockerMutationRecovery(), + runRunner: run, + })).rejects.toThrow(/recovery is incomplete/u); + expect(run).not.toHaveBeenCalled(); + expect(finalizeAuthority).not.toHaveBeenCalled(); + expect(writeDockerDeploymentRecordForRun).not.toHaveBeenCalled(); + }); + + it("adopts a lifecycle-verified detached container before finalizing a pending handoff", async () => { + vi.mocked(buildProject).mockResolvedValue(buildResult()); + vi.mocked(createDockerRunInvocation).mockResolvedValue({ + args: [], command: "docker", containerName: "football", cwd: "/tmp/spawnfile-handoff", detach: true, + deploymentName: "football", deploymentLabels, dockerContext: null, dockerHost: null, envFilePath: "/tmp/spawnfile-handoff.env", + imageTag: "football:latest", supportDirectory: "/tmp/spawnfile-handoff-support", + }); + beginAuthority.mockResolvedValue({ created: false, pending: { pending_key: "a".repeat(64) } }); + const run = vi.fn(async () => runMetadata); + await upProject("/tmp/project", { + ...complete, + lifecycleRecovery: detachedContainerRecovery(runMetadata), + runRunner: run, + }); + expect(run).not.toHaveBeenCalled(); + expect(recoverDetachedDockerRun).toHaveBeenCalledWith(expect.anything(), runMetadata.containerId); + expect(observeAuthority).toHaveBeenCalledWith("a".repeat(64), expect.objectContaining(runMetadata)); + }); + it("disposes authority even when pre-run detached env cleanup fails, preserving the cleanup error", async () => { vi.mocked(buildProject).mockResolvedValue(buildResult()); vi.mocked(createDockerRunInvocation).mockResolvedValue({ diff --git a/src/compiler/upReceipt.test.ts b/src/compiler/upReceipt.test.ts index 0d4383d5..07558e0b 100644 --- a/src/compiler/upReceipt.test.ts +++ b/src/compiler/upReceipt.test.ts @@ -311,6 +311,40 @@ describe("buildUpReceipt", () => { expect(receipt.engines).toEqual([{ agent: "agent:eleanor", engine: "scripted" }]); }); + it("preserves an explicit local dual-bridge Moltnet identity without relabeling it as public", async () => { + const fixtureDirectory = await createSingleAgentFixture(); + const outputDirectory = await createTempDirectory("spawnfile-up-receipt-compiled-"); + const upResult = createUpResult(outputDirectory, null); + upResult.report = { + ...upResult.report, + container: { + ...upResult.report.container!, + moltnet: { + ...upResult.report.container!.moltnet!, + release: { + architecture: "amd64", asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: `sha256:${"d".repeat(64)}`, + capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + source_sha256: `sha256:${"e".repeat(64)}`, + version: "spawnfile.moltnet-release-identity.v1" + } + } + } + }; + + const receipt = await buildUpReceipt(fixtureDirectory, upResult); + + expect(receipt.moltnet_release).toEqual({ + architecture: "amd64", asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: `sha256:${"d".repeat(64)}`, + capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + source_sha256: `sha256:${"e".repeat(64)}`, + version: "spawnfile.moltnet-release-identity.v1" + }); + }); + it("reports unknown readiness and null deployment name with no deployment record (non-detached run)", async () => { const fixtureDirectory = await createSingleAgentFixture(); const outputDirectory = await createTempDirectory("spawnfile-up-receipt-compiled-"); diff --git a/src/compiler/upReceipt.ts b/src/compiler/upReceipt.ts index 467d26d7..ed2979cc 100644 --- a/src/compiler/upReceipt.ts +++ b/src/compiler/upReceipt.ts @@ -45,6 +45,44 @@ export const resolveCompiledEngines = ( .map(([agent, engine]) => ({ agent, engine })) .sort((left, right) => left.agent.localeCompare(right.agent)); +const createReceiptMoltnetIdentity = ( + release: NonNullable["moltnet"] extends infer Moltnet + ? Moltnet extends { release?: infer Identity } ? Identity : never + : never +) => { + if (!release) return undefined; + if (release.capabilities.length === 1) { + if (release.capabilities[0] !== "pi-bridge" || !release.release_version || !release.source_revision) { + throw new SpawnfileError("runtime_error", "Published Moltnet receipt lacks its pinned source identity"); + } + return { + architecture: release.architecture, + asset: release.asset, + asset_sha256: release.asset_sha256, + capabilities: ["pi-bridge"] as ["pi-bridge"], + release_version: release.release_version, + source_revision: release.source_revision, + version: release.version + }; + } + if ( + release.capabilities.join("\0") !== "daimon-bridge\0pi-bridge" || + !release.development || + !release.source_sha256 + ) { + throw new SpawnfileError("runtime_error", "Local Moltnet receipt lacks its development provenance"); + } + return { + architecture: release.architecture, + asset: release.asset, + asset_sha256: release.asset_sha256, + capabilities: ["daimon-bridge", "pi-bridge"] as ["daimon-bridge", "pi-bridge"], + development: release.development, + source_sha256: release.source_sha256, + version: release.version + }; +}; + /** * Builds `spawnfile.up-receipt.v1` from a project-path `upProject()` result. Reads back * the deployment record `upProject` already wrote (for `run_id`/deployment name/container @@ -95,10 +133,7 @@ export const buildUpReceipt = async ( compiled_schedule: compiledSchedule, engines: compiledEngines, ...(result.report.container?.moltnet?.release - ? { moltnet_release: { - ...result.report.container.moltnet.release, - capabilities: ["pi-bridge"] as ["pi-bridge"] - } } + ? { moltnet_release: createReceiptMoltnetIdentity(result.report.container.moltnet.release) } : {}), ...(record?.organization_handoff ? { organization_handoff: record.organization_handoff } : {}), ...(record?.organization_handoff_handle ? { organization_handoff_handle: record.organization_handoff_handle } : {}), diff --git a/src/compiler/worldBindings.test.ts b/src/compiler/worldBindings.test.ts index b0ade80a..67fd9f77 100644 --- a/src/compiler/worldBindings.test.ts +++ b/src/compiler/worldBindings.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import type { CompilePlan, ResolvedAgentNode, ResolvedTeamNode } from "./types.js"; +import { resolveOrganizationIdentity } from "./organizationIdentity.js"; import { findWorldBindingForNode, parseSimfileWorldBindings, @@ -242,6 +243,16 @@ describe("simfile.world-bindings.v1", () => { expect(Object.isFrozen(first.assignments[0]?.binding)).toBe(true); }); + it("joins every agent in an ordinary organization with zero external participants", () => { + const current = plan(); + const root = current.nodes.find((node) => node.kind === "team")?.value as ResolvedTeamNode; + root.externalParticipants = undefined; + current.organizationIdentity = resolveOrganizationIdentity(current); + expect(current.organizationIdentity?.externalParticipants).toEqual([]); + expect(resolveWorldBindings(current, artifact()).assignments.map(({ nodeId }) => nodeId)) + .toEqual(["runtime:blue", "runtime:red"]); + }); + it("fails closed for missing, extra, wrong-principal, and duplicate joins", () => { expect(() => resolveWorldBindings(plan(), { schema: SIMFILE_WORLD_BINDINGS_VERSION, diff --git a/src/deployment/AGENTS.md b/src/deployment/AGENTS.md index 3c249fe3..6f2ac7c5 100644 --- a/src/deployment/AGENTS.md +++ b/src/deployment/AGENTS.md @@ -27,7 +27,8 @@ src/deployment/ ├── downDeployment.ts # `spawnfile down` orchestrator: record-driven teardown + the export-before-teardown guard (refuse/force/export-to) ├── lifecycleCompletionStore.ts # Strict lifecycle record reads and immutable publication ├── lifecycleCompletionPaths.ts # Lifecycle completion path and record-name validation -└── lifecycleCompletionRoot.ts # Anchored lifecycle-store root creation and revalidation +├── lifecycleCompletionRoot.ts # Anchored lifecycle-store root creation and revalidation +└── lifecycleUpRecords.ts # Pre-effect up reservations, detached-start records, and verified cleanup markers ``` ## Rules diff --git a/src/deployment/index.ts b/src/deployment/index.ts index 57388c96..83ac3e83 100644 --- a/src/deployment/index.ts +++ b/src/deployment/index.ts @@ -16,6 +16,7 @@ export * from "./downDeployment.js"; export * from "./downReceiptTypes.js"; export * from "./homeStore.js"; export * from "./lifecycleCompletion.js"; +export * from "./lifecycleUpRecords.js"; export * from "./names.js"; export * from "./organizationHandoffTypes.js"; export * from "./organizationHandoffAuthorityTypes.js"; diff --git a/src/deployment/lifecycleCompletion.test.ts b/src/deployment/lifecycleCompletion.test.ts index 3e4ea307..ac35a94a 100644 --- a/src/deployment/lifecycleCompletion.test.ts +++ b/src/deployment/lifecycleCompletion.test.ts @@ -25,6 +25,13 @@ import { type LifecycleInvocation, type LifecycleOwnerCapability, } from "./lifecycleCompletion.js"; +import { + findLifecycleUpReservation, + findLifecycleUpStart, + recordLifecycleUpCleanup, + recordLifecycleUpReservation, + recordLifecycleUpStart, +} from "./lifecycleUpRecords.js"; import { setLifecycleStoreTestHook } from "./lifecycleCompletionStore.js"; import { matchesSettledLifecyclePublication } from "./lifecycleCompletionPublication.js"; @@ -476,10 +483,14 @@ describe("lifecycle completion store", () => { const copy = `${file}.copy`; await link(file, copy); expect((await lstat(file)).nlink).toBe(2); + // This deliberately exceeds the former short settle window. A real + // competing publisher can be delayed by unrelated lifecycle work, but its + // exact temporary link must still settle before a second claimant fails. + const delayedUnlinkYields = 20; let yields = 0; globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { yields += 1; - if (yields === 4) { + if (yields === delayedUnlinkYields) { void rm(copy).then(() => callback()); } else { originalSetImmediate(callback); @@ -489,7 +500,7 @@ describe("lifecycle completion store", () => { await expect(claimLifecycleInvocation(transient)).resolves.toMatchObject({ status: "pending", }); - expect(yields).toBeGreaterThanOrEqual(4); + expect(yields).toBeGreaterThanOrEqual(delayedUnlinkYields); const bytes = JSON.stringify( { @@ -546,4 +557,42 @@ describe("lifecycle completion store", () => { "Lifecycle completion store refused: publication did not settle", ); }); + + it("binds a detached up start to its pre-effect reservation and permits an exact cleaned retry", async () => { + const up = invocation({ + id: `lci_${"u".repeat(16)}`, + operation: "up", + request_policy: { detach: true }, + }); + const capability = await claimOwner(up); + const reservation = { + container_name: "detached-organization", + docker_command: "docker", + docker_context: null, + label_authority: { + permitted_extra_labels: "image-config-labels" as const, + required: { "dev.spawnfile.deployment": "default" }, + }, + }; + const start = { + container_id: "c".repeat(64), + container_name: "detached-organization", + image_id: `sha256:${"d".repeat(64)}`, + label_authority: reservation.label_authority, + }; + await recordLifecycleUpReservation(up, reservation, capability); + await expect(findLifecycleUpReservation(up)).resolves.toMatchObject(reservation); + await recordLifecycleUpStart(up, start, capability); + await expect(findLifecycleUpStart(up)).resolves.toMatchObject({ attempt: 0, start }); + await expect(recordLifecycleUpStart(up, { ...start, container_name: "other" }, capability)) + .rejects.toThrow("up start authority drift"); + const active = await findLifecycleUpStart(up); + if (!active) throw new Error("expected active up start"); + await recordLifecycleUpCleanup(up, active, capability); + await recordLifecycleUpStart(up, { ...start, container_id: "e".repeat(64) }, capability); + await expect(findLifecycleUpStart(up)).resolves.toMatchObject({ + attempt: 1, + start: { ...start, attempt: 1, container_id: "e".repeat(64) }, + }); + }); }); diff --git a/src/deployment/lifecycleCompletionContracts.ts b/src/deployment/lifecycleCompletionContracts.ts index c79a4321..d112a920 100644 --- a/src/deployment/lifecycleCompletionContracts.ts +++ b/src/deployment/lifecycleCompletionContracts.ts @@ -15,6 +15,11 @@ export const LIFECYCLE_AMBIGUOUS_VERSION = "spawnfile.lifecycle-ambiguous.v1" as const; export const LIFECYCLE_TERMINAL_VERSION = "spawnfile.lifecycle-terminal.v1" as const; +export const LIFECYCLE_UP_START_VERSION = "spawnfile.lifecycle-up-start.v1" as const; +export const LIFECYCLE_UP_RESERVATION_VERSION = "spawnfile.lifecycle-up-reservation.v1" as const; +export const LIFECYCLE_UP_CLEANUP_VERSION = "spawnfile.lifecycle-up-cleanup.v1" as const; +// Docker carries an image's config labels onto a container; no other extras are allowed. +export const LIFECYCLE_UP_EXTRA_LABELS = "image-config-labels" as const; export const LIFECYCLE_RECORD_MAX_BYTES = 1_000_000; export const lifecycleIdSchema = z .string() @@ -94,6 +99,38 @@ export const lifecycleCompletionSchema = z }) .strict(); export type LifecycleCompletion = z.infer; +const lifecycleUpLabelAuthoritySchema = z.object({ + required: z.record(z.string().min(1).max(255), z.string().max(4096)), + permitted_extra_labels: z.literal(LIFECYCLE_UP_EXTRA_LABELS), +}).strict(); +export const lifecycleUpReservationSchema = z.object({ + container_name: z.string().min(1).max(255), + docker_command: z.string().min(1).max(4096), + docker_context: z.string().min(1).max(128).nullable(), + invocation: lifecycleInvocationSchema, + label_authority: lifecycleUpLabelAuthoritySchema, + version: z.literal(LIFECYCLE_UP_RESERVATION_VERSION), +}).strict(); +export type LifecycleUpReservation = z.infer; +export const lifecycleUpStartSchema = z + .object({ + attempt: z.number().int().min(0).max(16), + container_id: z.string().regex(/^[a-f0-9]{64}$/u), + container_name: z.string().min(1).max(255), + image_id: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + invocation: lifecycleInvocationSchema, + label_authority: lifecycleUpLabelAuthoritySchema, + version: z.literal(LIFECYCLE_UP_START_VERSION), + }) + .strict(); +export type LifecycleUpStart = z.infer; +export const lifecycleUpCleanupSchema = z.object({ + attempt: z.number().int().min(0).max(16), + container_id: z.string().regex(/^[a-f0-9]{64}$/u), + invocation: lifecycleInvocationSchema, + version: z.literal(LIFECYCLE_UP_CLEANUP_VERSION), +}).strict(); +export type LifecycleUpCleanup = z.infer; export const lifecycleTerminalSchema = z.discriminatedUnion("status", [ z .object({ diff --git a/src/deployment/lifecycleCompletionPaths.ts b/src/deployment/lifecycleCompletionPaths.ts index 3026a442..28708daf 100644 --- a/src/deployment/lifecycleCompletionPaths.ts +++ b/src/deployment/lifecycleCompletionPaths.ts @@ -16,12 +16,12 @@ export const resolveLifecycleCompletionDirectory = (): string => export const lifecycleRecordName = ( id: string, - kind: "admission" | "completion" | "evidence" | "plan" | "recovery" + kind: "admission" | "completion" | "evidence" | "plan" | "recovery" | "up-reservation" ): string => `${lifecycleIdSchema.parse(id)}.${kind}`; const lifecyclePath = ( id: string, - kind: "admission" | "completion" | "evidence" | "plan" | "recovery" + kind: "admission" | "completion" | "evidence" | "plan" | "recovery" | "up-reservation" ): string => path.join(resolveLifecycleCompletionDirectory(), lifecycleRecordName(id, kind)); export const resolveLifecycleCompletionPath = (id: string): string => @@ -30,5 +30,10 @@ export const admissionPath = (id: string): string => lifecyclePath(id, "admissio export const planPath = (id: string): string => lifecyclePath(id, "plan"); export const recoveryPath = (id: string): string => lifecyclePath(id, "recovery"); export const evidencePath = (id: string): string => lifecyclePath(id, "evidence"); +export const upReservationPath = (id: string): string => lifecyclePath(id, "up-reservation"); +export const upStartPath = (id: string, attempt: number): string => + path.join(resolveLifecycleCompletionDirectory(), `${lifecycleIdSchema.parse(id)}.up-start-${attempt}`); +export const upCleanupPath = (id: string, attempt: number): string => + path.join(resolveLifecycleCompletionDirectory(), `${lifecycleIdSchema.parse(id)}.up-cleanup-${attempt}`); export const heartbeatPath = (id: string): string => path.join(resolveLifecycleCompletionDirectory(), `${lifecycleIdSchema.parse(id)}.heartbeat`); diff --git a/src/deployment/lifecycleCompletionPublication.ts b/src/deployment/lifecycleCompletionPublication.ts index 4df0df7c..2a45727d 100644 --- a/src/deployment/lifecycleCompletionPublication.ts +++ b/src/deployment/lifecycleCompletionPublication.ts @@ -7,6 +7,12 @@ type RecordReader = ( links?: readonly number[], ) => Promise; +// A competing publisher removes its temporary hard link asynchronously. A +// short run of event-loop turns can elapse before that unlink is scheduled +// when many lifecycle operations are active, so leave a bounded but practical +// window before treating an extra link as hostile. +export const LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS = 64; + const refuse = (message: string): never => { throw new SpawnfileError( "runtime_error", @@ -17,7 +23,7 @@ const refuse = (message: string): never => { export const settleLifecyclePublication = async ( file: string, ): Promise => { - for (let attempt = 0; attempt < 16; attempt += 1) { + for (let attempt = 0; attempt < LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS; attempt += 1) { const info = await lstat(file).catch(() => refuse("publication changed")); if (info.nlink === 1) return; await new Promise((resolve) => setImmediate(resolve)); diff --git a/src/deployment/lifecycleCompletionStore.ts b/src/deployment/lifecycleCompletionStore.ts index 5cd0b86f..bf07aba8 100644 --- a/src/deployment/lifecycleCompletionStore.ts +++ b/src/deployment/lifecycleCompletionStore.ts @@ -18,6 +18,7 @@ import { type LifecycleRootAuthority } from "./lifecycleCompletionRoot.js"; import { + LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS, matchesSettledLifecyclePublication, readSettledLifecycleRecord } from "./lifecycleCompletionPublication.js"; @@ -30,6 +31,9 @@ export { lifecycleRecordName, planPath, recoveryPath, + upCleanupPath, + upReservationPath, + upStartPath, resolveLifecycleCompletionDirectory, resolveLifecycleCompletionPath } from "./lifecycleCompletionPaths.js"; @@ -179,7 +183,7 @@ export const publishLifecycleRecord = async ( } return false; } - for (let attempt = 0; attempt < 16; attempt += 1) { + for (let attempt = 0; attempt < LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS; attempt += 1) { const exact = await readLifecycleRecord(final, [1, 2]); if (exact !== content) failLifecycleStore("publication changed"); if ((await lstat(final).catch(() => null))?.nlink === 1) { diff --git a/src/deployment/lifecycleUpRecords.ts b/src/deployment/lifecycleUpRecords.ts new file mode 100644 index 00000000..bf0dc92e --- /dev/null +++ b/src/deployment/lifecycleUpRecords.ts @@ -0,0 +1,177 @@ +import path from "node:path"; + +import { readSettledLifecycleRecord } from "./lifecycleCompletionPublication.js"; +import { + canonicalLifecycleJson, + lifecycleUpCleanupSchema, + lifecycleUpReservationSchema, + lifecycleUpStartSchema, + LIFECYCLE_UP_CLEANUP_VERSION, + LIFECYCLE_UP_RESERVATION_VERSION, + LIFECYCLE_UP_START_VERSION, + type LifecycleAdmission, + type LifecycleInvocation, + type LifecycleOwnerCapability, + type LifecycleUpReservation, + type LifecycleUpStart, +} from "./lifecycleCompletionContracts.js"; +import { assertLifecycleOwnerCapability } from "./lifecycleCompletionOwner.js"; +import { parseLifecycleAdmission, parseLifecycleInvocation } from "./lifecycleCompletionParsing.js"; +import { + admissionPath, + failLifecycleStore, + lifecycleRecordName, + lifecycleRoot, + publishLifecycleRecord, + readLifecycleRecord, + recoveryPath, + upCleanupPath, + upReservationPath, + upStartPath, +} from "./lifecycleCompletionStore.js"; + +export { + LIFECYCLE_UP_EXTRA_LABELS, + type LifecycleUpReservation, + type LifecycleUpStart, +} from "./lifecycleCompletionContracts.js"; + +const MAX_ATTEMPTS = 16; +const canonical = canonicalLifecycleJson; +const fail = failLifecycleStore; + +const exactAdmission = async (invocation: LifecycleInvocation): Promise => { + const text = await readSettledLifecycleRecord(admissionPath(invocation.id), readLifecycleRecord); + if (text === null) return fail("missing admission"); + const admission = parseLifecycleAdmission(text); + if (canonical(admission.invocation) !== canonical(invocation)) fail("invocation id drift"); + return admission; +}; + +const assertOwner = async ( + invocation: LifecycleInvocation, + capability: LifecycleOwnerCapability, +): Promise => { + const recoveryText = await readSettledLifecycleRecord(recoveryPath(invocation.id), readLifecycleRecord); + const recovered = recoveryText ? parseLifecycleAdmission(recoveryText) : null; + assertLifecycleOwnerCapability(invocation, await exactAdmission(invocation), recovered, capability); +}; + +const parseRecord = (text: string, parse: (raw: unknown) => T, invocation: LifecycleInvocation): T => { + const value = (() => { + try { return parse(JSON.parse(text)); } catch { return fail("invalid up lifecycle record"); } + })(); + const record = value as { invocation: LifecycleInvocation }; + if (`${canonical(value!)}\n` !== text || canonical(record.invocation) !== canonical(invocation)) { + fail("invocation id drift"); + } + return value!; +}; + +export const findLifecycleUpReservation = async ( + raw: LifecycleInvocation, +): Promise => { + const invocation = parseLifecycleInvocation(raw); + if (invocation.operation !== "up") fail("non-up reservation lookup"); + await lifecycleRoot(); + const text = await readSettledLifecycleRecord(upReservationPath(invocation.id), readLifecycleRecord); + return text === null ? null : parseRecord(text, (value) => lifecycleUpReservationSchema.parse(value), invocation); +}; + +export const recordLifecycleUpReservation = async ( + raw: LifecycleInvocation, + reservation: Omit, + capability: LifecycleOwnerCapability, +): Promise => { + const invocation = parseLifecycleInvocation(raw); + if (invocation.operation !== "up") fail("non-up reservation record"); + const directory = await lifecycleRoot(); + await assertOwner(invocation, capability); + const value = lifecycleUpReservationSchema.parse({ + ...reservation, invocation, version: LIFECYCLE_UP_RESERVATION_VERSION, + }); + await publishLifecycleRecord(directory, lifecycleRecordName(invocation.id, "up-reservation"), `${canonical(value)}\n`); +}; + +export interface LifecycleUpStartState { + readonly attempt: number; + readonly start: LifecycleUpStart; +} + +const readStart = async (invocation: LifecycleInvocation, attempt: number): Promise => { + const text = await readSettledLifecycleRecord(upStartPath(invocation.id, attempt), readLifecycleRecord); + return text === null ? null : parseRecord(text, (value) => lifecycleUpStartSchema.parse(value), invocation); +}; + +const readCleanup = async (invocation: LifecycleInvocation, attempt: number) => { + const text = await readSettledLifecycleRecord(upCleanupPath(invocation.id, attempt), readLifecycleRecord); + return text === null ? null : parseRecord(text, (value) => lifecycleUpCleanupSchema.parse(value), invocation); +}; + +const findUpAttempt = async (invocation: LifecycleInvocation): Promise<{ + active: LifecycleUpStartState | null; + next: number; +}> => { + for (let attempt = 0; attempt <= MAX_ATTEMPTS; attempt += 1) { + const start = await readStart(invocation, attempt); + if (start === null) { + if (await readCleanup(invocation, attempt)) fail("up cleanup without start"); + return { active: null, next: attempt }; + } + if (start.attempt !== attempt) fail("up start attempt drift"); + const cleanup = await readCleanup(invocation, attempt); + if (cleanup === null) return { active: { attempt, start }, next: attempt }; + if (cleanup.attempt !== attempt || cleanup.container_id !== start.container_id) fail("up cleanup drift"); + } + return fail("up retry limit reached"); +}; + +export const findLifecycleUpStart = async (raw: LifecycleInvocation): Promise => { + const invocation = parseLifecycleInvocation(raw); + if (invocation.operation !== "up") fail("non-up start lookup"); + await lifecycleRoot(); + return (await findUpAttempt(invocation)).active; +}; + +const sameAuthority = (reservation: LifecycleUpReservation, start: LifecycleUpStart): boolean => + reservation.container_name === start.container_name + && canonical(reservation.label_authority) === canonical(start.label_authority); + +export const recordLifecycleUpStart = async ( + raw: LifecycleInvocation, + start: Omit, + capability: LifecycleOwnerCapability, +): Promise => { + const invocation = parseLifecycleInvocation(raw); + if (invocation.operation !== "up") fail("non-up start record"); + const directory = await lifecycleRoot(); + await assertOwner(invocation, capability); + const reservation = await findLifecycleUpReservation(invocation); + if (!reservation) return fail("missing up reservation"); + const current = await findUpAttempt(invocation); + const value = lifecycleUpStartSchema.parse({ ...start, attempt: current.next, invocation, version: LIFECYCLE_UP_START_VERSION }); + if (!sameAuthority(reservation, value)) fail("up start authority drift"); + if (current.active) { + if (canonical(current.active.start) !== canonical(value)) fail("active up start changed"); + return; + } + await publishLifecycleRecord(directory, path.basename(upStartPath(invocation.id, current.next)), `${canonical(value)}\n`); +}; + +export const recordLifecycleUpCleanup = async ( + raw: LifecycleInvocation, + state: LifecycleUpStartState, + capability: LifecycleOwnerCapability, +): Promise => { + const invocation = parseLifecycleInvocation(raw); + const directory = await lifecycleRoot(); + await assertOwner(invocation, capability); + const active = await findUpAttempt(invocation); + if (!active.active || active.active.attempt !== state.attempt + || canonical(active.active.start) !== canonical(state.start)) fail("up cleanup without active start"); + const value = lifecycleUpCleanupSchema.parse({ + attempt: state.attempt, container_id: state.start.container_id, invocation, + version: LIFECYCLE_UP_CLEANUP_VERSION, + }); + await publishLifecycleRecord(directory, path.basename(upCleanupPath(invocation.id, state.attempt)), `${canonical(value)}\n`); +}; diff --git a/src/deployment/organizationHandoffAuthorityFsClient.test.ts b/src/deployment/organizationHandoffAuthorityFsClient.test.ts index 9e638343..e1669a20 100644 --- a/src/deployment/organizationHandoffAuthorityFsClient.test.ts +++ b/src/deployment/organizationHandoffAuthorityFsClient.test.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from "node:child_process"; -import { lstat, mkdtemp, rm } from "node:fs/promises"; +import { lstat, mkdtemp, readdir, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -21,3 +21,24 @@ it("disposes promptly and idempotently after a worker has already exited by sign await Promise.race([client.dispose(), new Promise((_resolve, reject) => setTimeout(() => reject(new Error("dispose did not settle")), 500))]); await client.dispose(); }); + +it("converges full-size concurrent publishers without leaving staging sidecars", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-handoff-fs-client-")); directories.push(directory); + const stat = await lstat(directory); + const options = { + cwd: directory, dev: stat.dev, ino: stat.ino, + ...(typeof process.getuid === "function" ? { uid: process.getuid() } : {}), + }; + const clients = await Promise.all(Array.from({ length: 8 }, async () => initializeOrganizationHandoffAuthorityFsClient(options))); + const leaves: string[] = []; + try { + for (let index = 0; index < 8; index += 1) { + const name = `${index.toString(16).padStart(128, "0")}.json`; leaves.push(name); + const results = await Promise.all(clients.map(async (client) => client.create(name, "x".repeat(30_000)))); + expect(results.filter(Boolean)).toHaveLength(1); + expect((await readdir(directory)).sort()).toEqual([...leaves].sort()); + } + } finally { + await Promise.all(clients.map(async (client) => client.dispose())); + } +}); diff --git a/src/deployment/organizationHandoffAuthorityFsWorker.ts b/src/deployment/organizationHandoffAuthorityFsWorker.ts index 0b5291a1..f2fbe0c3 100644 --- a/src/deployment/organizationHandoffAuthorityFsWorker.ts +++ b/src/deployment/organizationHandoffAuthorityFsWorker.ts @@ -3,6 +3,8 @@ import { link, lstat, open, unlink } from "node:fs/promises"; const VERSION = "spawnfile.organization-handoff-fs-worker.v1"; const MAX_BYTES = 32_768; +const PUBLICATION_READ_ATTEMPTS = 64; +const PUBLICATION_SETTLE_ATTEMPTS = 64; // Store keys encode either a 64-character pending key or a 71-character // `opaque_` handoff handle. No other leaf namespace is reachable. const NAME = /^(?:[a-f0-9]{128}|[a-f0-9]{142})\.json$/u; @@ -99,29 +101,111 @@ const readDuringPublication = async (name: string, attempt = 0): Promise= 32 || election !== true) return fail(); + if (attempt >= PUBLICATION_READ_ATTEMPTS || election !== true) return fail(); await waitForPublisher(); return readDuringPublication(name, attempt + 1); } }; +type StagingState = "absent" | "exact" | "expected-prefix"; +/** + * A sidecar may change between lstat and fd inspection while another worker + * publishes. Re-prove an exact immutable final first, then retry the sidecar + * so cleanup still relies on a stable checked sidecar rather than an inference. + */ +const readStaging = async ( + staging: string, final: string, content: string, attempt = 0 +): Promise => { + try { + const observed = await read(staging); + if (observed === null) return "absent"; + if (observed === content) return "exact"; + return content.startsWith(observed) ? "expected-prefix" : fail(); + } catch { + const published = await readDuringPublication(final); + if (published !== null) { + if (published !== content || attempt >= PUBLICATION_READ_ATTEMPTS) return fail(); + await waitForPublisher(); return readStaging(staging, final, content, attempt + 1); + } + const election = await expectedElectionState(staging); + if (election === null) return "absent"; + if (election !== true || attempt >= PUBLICATION_READ_ATTEMPTS) return fail(); + await waitForPublisher(); return readStaging(staging, final, content, attempt + 1); + } +}; +const publicationSidecars = (name: string): readonly string[] => [`${name}.pending`, `${name}.recovery`]; +/** + * A successful link election can race a peer which had already created the + * other staging leaf. The final immutable record is authoritative, but the + * stale leaf must not survive a completed join: it would otherwise be + * mistaken for an in-progress publication after restart. Delete exact bytes + * immediately. An incomplete expected prefix may still belong to a publisher, + * so wait for it first; once that bounded wait expires, removing that proven + * prefix is safe because the final record already prevents it from winning a + * later link election. + */ +const settlePublished = async (name: string, content: string, attempt = 0): Promise => { + if (attempt > PUBLICATION_SETTLE_ATTEMPTS || await readDuringPublication(name) !== content) return fail(); + let incomplete = false; + for (const sidecar of publicationSidecars(name)) { + const observed = await readStaging(sidecar, name, content); + if (observed === "absent") continue; + if (observed === "expected-prefix" && attempt < PUBLICATION_SETTLE_ATTEMPTS) { + incomplete = true; continue; + } + await unlink(sidecar).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") fail(); }); + await sync(); + } + if (incomplete) { + await waitForPublisher(); return settlePublished(name, content, attempt + 1); + } + if (await readDuringPublication(name) !== content) return fail(); + const remaining = await Promise.all(publicationSidecars(name).map(async (sidecar) => readStaging(sidecar, name, content))); + if (remaining.every((sidecar) => sidecar === "absent")) return; + if (attempt >= PUBLICATION_SETTLE_ATTEMPTS) return fail(); + await waitForPublisher(); return settlePublished(name, content, attempt + 1); +}; +const readPublished = async (name: string): Promise => { + const content = await readDuringPublication(name); + if (content === null) return null; + await settlePublished(name, content); return content; +}; const write = async (name: string, content: string, attempt = 0): Promise => { - if (attempt > 32) return fail(); + if (attempt > PUBLICATION_SETTLE_ATTEMPTS) return fail(); const joinOrRetry = async (): Promise => { const published = await readDuringPublication(name); - if (published !== null) { if (published !== content) return fail(); return false; } + if (published !== null) { if (published !== content) return fail(); await settlePublished(name, content); return false; } await waitForPublisher(); return write(name, content, attempt + 1); }; - const existing = await readDuringPublication(name); if (existing !== null) { if (existing !== content) fail(); return false; } - const pending = `${name}.pending`; const recovery = `${name}.recovery`; const incomplete = await readDuringPublication(pending); - if (incomplete !== null && incomplete !== content && !content.startsWith(incomplete)) return fail(); - if (incomplete !== null && incomplete !== content) { - const recovered = await readDuringPublication(recovery); if (recovered !== null && recovered !== content) return fail(); - if (recovered === null) { + const reproveStaging = async (): Promise => { + const published = await readDuringPublication(name); + if (published === content) { await settlePublished(name, content); return true; } + if (published !== null) return fail(); + await waitForPublisher(); return false; + }; + const nextAttempt = (): number => attempt + 1; + const existing = await readDuringPublication(name); if (existing !== null) { if (existing !== content) fail(); await settlePublished(name, content); return false; } + const pending = `${name}.pending`; const recovery = `${name}.recovery`; const incomplete = await readStaging(pending, name, content); + if (incomplete === "expected-prefix") { + let recovered = await readStaging(recovery, name, content); + if (recovered === "expected-prefix") { + if (attempt < PUBLICATION_SETTLE_ATTEMPTS) { + await waitForPublisher(); return write(name, content, attempt + 1); + } + // A crashed recovery publisher can leave the same bounded prefix. It + // cannot win after this writer links the immutable final record, so + // retire it only after the full wait budget and reconstruct it below. + await unlink(recovery).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") fail(); }); + await sync(); recovered = "absent"; + } + if (recovered === "absent") { const handle = await open(recovery, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600).catch((error: NodeJS.ErrnoException) => error.code === "EEXIST" ? null : fail()); if (handle === null) { await waitForPublisher(); return write(name, content, attempt + 1); } try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close().catch(() => undefined); } - const staged = await readDuringPublication(recovery); - if (staged === null && await readDuringPublication(name) === content) return false; - if (staged !== content) return fail(); await sync(); + const staged = await readStaging(recovery, name, content); + if (staged !== "exact") { + if (await reproveStaging()) return false; + return write(name, content, nextAttempt()); + } + await sync(); } const recoveredLinked = await link(recovery, name).then(() => true).catch((error: NodeJS.ErrnoException) => { if (error.code === "EEXIST") return false; @@ -130,15 +214,18 @@ const write = async (name: string, content: string, attempt = 0): Promise { if (error.code !== "ENOENT") fail(); }); await sync(); - if (await readDuringPublication(name) !== content) fail(); return true; + if (await readDuringPublication(name) !== content) fail(); await settlePublished(name, content); return true; } - if (incomplete === null) { + if (incomplete === "absent") { const handle = await open(pending, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600).catch((error: NodeJS.ErrnoException) => error.code === "EEXIST" ? null : fail()); if (handle === null) { await waitForPublisher(); return write(name, content, attempt + 1); } try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close().catch(() => undefined); } - const staged = await readDuringPublication(pending); - if (staged === null && await readDuringPublication(name) === content) return false; - if (staged !== content) return fail(); await sync(); + const staged = await readStaging(pending, name, content); + if (staged !== "exact") { + if (await reproveStaging()) return false; + return write(name, content, nextAttempt()); + } + await sync(); } const linked = await link(pending, name).then(() => true).catch((error: NodeJS.ErrnoException) => { if (error.code === "EEXIST") return false; @@ -147,7 +234,7 @@ const write = async (name: string, content: string, attempt = 0): Promise { if (error.code !== "ENOENT") fail(); }); await sync(); - if (await readDuringPublication(name) !== content) fail(); return true; + if (await readDuringPublication(name) !== content) fail(); await settlePublished(name, content); return true; }; let anchor: Anchor | undefined; let queue = Promise.resolve(); @@ -164,7 +251,7 @@ process.on("message", (raw: unknown) => { const request = validRequest(raw); if (!anchor) return fail(); const stat = await lstat("."); if (stat.dev !== anchor.dev || stat.ino !== anchor.ino || (stat.mode & 0o077) !== 0) return fail(); const result = request.op === "read" - ? { content: await read(request.name) } + ? { content: await readPublished(request.name) } : { created: request.content === undefined ? fail() : await write(request.name, request.content) }; send({ version: VERSION, id: request.id, ok: true, ...(result.content === null ? {} : { content: result.content }), ...(request.op === "create" ? { created: result.created } : {}) }); }).catch(() => send({ version: VERSION, id: typeof raw === "object" && raw !== null && Number.isSafeInteger((raw as { id?: unknown }).id) ? (raw as { id: number }).id : 0, ok: false })); diff --git a/src/deployment/organizationHandoffAuthorityStore.test.ts b/src/deployment/organizationHandoffAuthorityStore.test.ts index e00186dd..da0a21ca 100644 --- a/src/deployment/organizationHandoffAuthorityStore.test.ts +++ b/src/deployment/organizationHandoffAuthorityStore.test.ts @@ -148,6 +148,30 @@ describe("organization handoff authority store", () => { expect(finalized.container_id).toBe("9".repeat(64)); }); + it("settles exact stale publication sidecars when a fresh worker joins a reservation", async () => { + const initial = await store(); const started = await initial.begin(input()); + const name = `${Buffer.from(createOrganizationHandoffRecoveryKey(started.pending.pending_key), "utf8").toString("hex")}.json`; + const directory = path.join(resolveOrganizationHandoffAuthorityRoot(), "recovery-reserved"); + const record = JSON.stringify(started.pending); const leaf = path.join(directory, name); + await writeFile(`${leaf}.pending`, record, { mode: 0o600 }); + await writeFile(`${leaf}.recovery`, record, { mode: 0o600 }); + await initial.dispose(); + const restarted = await initialize(); + await expect(restarted.begin(input())).resolves.toEqual({ created: false, pending: started.pending }); + expect(await readdir(directory)).toEqual([name]); + }); + + it("reconstructs a durable crash while both recovery publication leaves are prefixes", async () => { + const value = await store(); const pending = createOrganizationHandoffCapabilityPending(input()); + const name = `${Buffer.from(createOrganizationHandoffRecoveryKey(pending.pending_key), "utf8").toString("hex")}.json`; + const directory = path.join(resolveOrganizationHandoffAuthorityRoot(), "recovery-reserved"); + const prefix = JSON.stringify(pending).slice(0, 32); const leaf = path.join(directory, name); + await writeFile(`${leaf}.pending`, prefix, { mode: 0o600 }); + await writeFile(`${leaf}.recovery`, prefix, { mode: 0o600 }); + await expect(value.begin(input())).resolves.toEqual({ created: true, pending }); + expect(await readdir(directory)).toEqual([name]); + }); + it("fails closed for closure, corruption, symlinked authority, and a changed authorization before any provider seam", async () => { const value = await store(); const pending = await value.reserve(input()); const final = await value.finalize(pending.pending_key, { containerId: "3".repeat(64), deploymentLabels: labels }); diff --git a/src/deployment/upLifecycleRecoveryState.ts b/src/deployment/upLifecycleRecoveryState.ts new file mode 100644 index 00000000..fe2e4ca9 --- /dev/null +++ b/src/deployment/upLifecycleRecoveryState.ts @@ -0,0 +1,49 @@ +/** + * Ephemeral grants issued only by the durable up reconciler. They are kept + * process-local: a restart reconstructs them by re-reading and re-verifying + * the lifecycle records, never by trusting caller-supplied recovery data. + */ +export interface NoDockerMutationRecovery { + readonly kind: "no_docker_mutation"; +} + +export interface DeploymentRecordRecovery { + readonly kind: "deployment_record"; +} + +export interface DetachedContainerRecovery { + readonly containerId: string; + readonly containerName: string; + readonly deploymentLabels: Readonly>; + readonly imageId: string; + readonly kind: "detached_container"; +} + +export type UpLifecycleRecovery = + | NoDockerMutationRecovery + | DeploymentRecordRecovery + | DetachedContainerRecovery; + +const grants = new WeakSet(); + +const grant = (value: T): T => { + const result = Object.freeze(value); + grants.add(result); + return result; +}; + +export const noDockerMutationRecovery = (): NoDockerMutationRecovery => + grant({ kind: "no_docker_mutation" }); + +export const deploymentRecordRecovery = (): DeploymentRecordRecovery => + grant({ kind: "deployment_record" }); + +export const detachedContainerRecovery = ( + value: Omit, +): DetachedContainerRecovery => grant({ ...value, kind: "detached_container" }); + +export const isTrustedUpLifecycleRecovery = ( + value: unknown, +): value is UpLifecycleRecovery => value !== null + && typeof value === "object" + && grants.has(value); diff --git a/src/deployment/upReceiptTypes.ts b/src/deployment/upReceiptTypes.ts index 9b37a3cc..1ab28199 100644 --- a/src/deployment/upReceiptTypes.ts +++ b/src/deployment/upReceiptTypes.ts @@ -63,7 +63,7 @@ const compiledEngineEntrySchema = z export type CompiledEngineEntry = z.infer; -const moltnetReleaseIdentitySchema = z.object({ +const publishedMoltnetReleaseIdentitySchema = z.object({ architecture: z.union([z.literal("amd64"), z.literal("arm64")]), asset: z.string().regex(/^moltnet_linux_(amd64|arm64)\.tar\.gz$/u), asset_sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/u), @@ -89,6 +89,30 @@ const moltnetReleaseIdentitySchema = z.object({ } }); +const localMoltnetReleaseIdentitySchema = z.object({ + architecture: z.union([z.literal("amd64"), z.literal("arm64")]), + asset: z.string().regex(/^moltnet_linux_(amd64|arm64)\.tar\.gz$/u), + asset_sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + capabilities: z.tuple([z.literal("daimon-bridge"), z.literal("pi-bridge")]), + development: z.object({ + mode: z.literal("local-development"), + non_production: z.literal(true), + unsigned: z.literal(true), + unpublished: z.literal(true) + }).strict(), + source_sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + version: z.literal("spawnfile.moltnet-release-identity.v1") +}).strict().superRefine((value, context) => { + if (!value.asset.includes(`_${value.architecture}.`)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["asset"], message: "Moltnet asset architecture must match identity architecture" }); + } +}); + +const moltnetReleaseIdentitySchema = z.union([ + publishedMoltnetReleaseIdentitySchema, + localMoltnetReleaseIdentitySchema +]); + export type MoltnetReleaseReceiptIdentity = z.infer; const organizationHandoffReceiptSchema = z.object({ diff --git a/src/dev/project.test.ts b/src/dev/project.test.ts index 123f5387..6db4b56f 100644 --- a/src/dev/project.test.ts +++ b/src/dev/project.test.ts @@ -89,9 +89,9 @@ const addObserverAgent = async (projectDirectory: string): Promise => { 'spawnfile_version: "0.1"', "kind: agent", "name: observer", - 'description: "Observes the Daimon dev loop."', + 'description: "Observes the legacy Pi dev loop."', "", - "runtime: daimon", + "runtime: pi", "", "execution:", " model:", @@ -246,7 +246,7 @@ afterEach(async () => { }); describe("devApplyProject", () => { - it("hot-applies a new Daimon agent and starts only its Moltnet bridge", async () => { + it("hot-applies a new legacy Pi agent and starts only its Moltnet bridge", async () => { const parentDirectory = await createTempDirectory("spawnfile-dev-project-"); const projectDirectory = path.join(parentDirectory, "org"); await cp(path.join(fixturesRoot, "daimon-org"), projectDirectory, { @@ -288,10 +288,10 @@ describe("devApplyProject", () => { existingAgent: false }); expect(calls.some((args) => - args.join(" ").includes("pi-app.json spawnfile-pi-dev:/var/lib/spawnfile/instances/daimon/pi-app/pi/pi-app.json") + args.join(" ").includes("pi-app.json spawnfile-pi-dev:/var/lib/spawnfile/instances/pi/pi-app/pi/pi-app.json") )).toBe(true); expect(calls.some((args) => - args.join(" ").includes("workspace/agents/observer spawnfile-pi-dev:/var/lib/spawnfile/instances/daimon/pi-app/workspace/agents") + args.join(" ").includes("workspace/agents/observer spawnfile-pi-dev:/var/lib/spawnfile/instances/pi/pi-app/workspace/agents") )).toBe(true); expect(calls.some((args) => args.join(" ").includes("/var/lib/spawnfile/moltnet/nodes/daimon-org-daimon_lab-observer.json") @@ -307,7 +307,7 @@ describe("devApplyProject", () => { expect(calls.some((args) => args.includes("chown") && args.includes("spawnfile:spawnfile") - && args.includes("/var/lib/spawnfile/instances/daimon/pi-app") + && args.includes("/var/lib/spawnfile/instances/pi/pi-app") && args.includes("/var/lib/spawnfile/moltnet/nodes/daimon-org-daimon_lab-observer.json") && args.includes("/var/lib/spawnfile/agents/observer/state/moltnet") )).toBe(true); @@ -322,7 +322,7 @@ describe("devApplyProject", () => { )).toBe(true); }, 40_000); - it("reloads an existing Daimon agent without starting a second bridge", async () => { + it("reloads an existing legacy Pi agent without starting a second bridge", async () => { const parentDirectory = await createTempDirectory("spawnfile-dev-project-"); const projectDirectory = path.join(parentDirectory, "org"); await cp(path.join(fixturesRoot, "daimon-org"), projectDirectory, { @@ -368,7 +368,7 @@ describe("devApplyProject", () => { expect(calls.some((args) => args.some((arg) => arg.endsWith("/spawnfile/agents/load")))).toBe(true); }, 40_000); - it("starts all Moltnet node bridges for a new Daimon agent", async () => { + it("starts all Moltnet node bridges for a new legacy Pi agent", async () => { const parentDirectory = await createTempDirectory("spawnfile-dev-project-"); const projectDirectory = path.join(parentDirectory, "org"); await cp(path.join(fixturesRoot, "daimon-org"), projectDirectory, { diff --git a/src/e2e/AGENTS.md b/src/e2e/AGENTS.md index eb0bacd9..cfd619ee 100644 --- a/src/e2e/AGENTS.md +++ b/src/e2e/AGENTS.md @@ -16,8 +16,8 @@ src/e2e/ ├── operationalSmokePicoclaw.ts # PicoClaw-specific operational smoke helpers ├── operationalSmokeStatus.ts # Assertions for operational spawnfile status --live JSON output ├── lifecycleSmoke.ts # spawnfile up/artifacts-export/down --json lifecycle smoke against a minimal SCRIPTED fixture — zero transcript/turn/behavior assertions (Decision 20/21, Slice B Piece 5 step 5); the coverage safety net that replaced officeSim*.ts/autonomousOfficeSim*.ts once the office-sim scenario itself migrated to ecosystem/simfile -├── daimonOrg.ts # Generated Daimon app smoke with real Codex auth — interim live-model regression check (Slice B note below), kept even though it also re-proves some already-unit-tested compiler wiring/memory persistence -├── memoryIntegration.ts # Compile/report memory wiring checks for Daimon, PicoClaw, and Jungian fixtures +├── daimonOrg.ts # Historical-name legacy generated-Pi app smoke with real Codex auth — interim live-model regression check (Slice B note below) +├── memoryIntegration.ts # Compile/report memory wiring checks for legacy Pi, PicoClaw, and Jungian fixtures ├── memoryIntegrationSupport.ts # Shared helpers for memory integration E2Es ├── ollamaProbe.ts # Optional local Ollama embeddings probe ├── preflight.ts # Local readiness report surface and B18 adapter @@ -84,7 +84,7 @@ replaces this folder's own e2e coverage of the up/export/down lifecycle. - A passing live agent communication run prints `Moltnet team-chat E2E passed (...)`. This means the generated container started Moltnet, attached the bridges, woke the OpenClaw/Codex agents, and observed both the parent request/ACK and child ACK messages. - **Interim live-model regression check (Slice B), do not delete:** `moltnetTeamChat.ts` (plus `moltnetTeamChatBusyTurn.ts` and `moltnetTeamChatB20.ts`, which share its plumbing) proves a busy-turn burst gets one real reply carrying every queued marker — genuinely unfakeable live-model behavior, not something a fake-engine unit test can stand in for. This is kept as-is pending the compose-and-observe pipeline (Spawnfile org + Simfile world, composed and observed read-only from `simfile`, per the project direction to delete bespoke orchestration harnesses once the platform gap they work around is fixed rather than reimplement them there). Do not touch its shared plumbing while it's still the only thing exercising this path. -- The Daimon org E2E compiles `examples/daimon-org`, injects real Codex OAuth into the generated Pi home, installs the generated Daimon runtime package, runs the generated app twice, and asserts that two Daimon agents wrote through a shared workspace resource and recorded/recalled Mneme memory. **Interim live-model regression check (Slice B), do not delete:** two real Codex agents actually writing to a shared workspace path is unfakeable live-model behavior, kept as-is pending the same compose-and-observe pipeline noted above (its compiler-wiring/memory-persistence assertions overlap with unit coverage elsewhere, but splitting those out was judged not worth the churn while this file is still flagged interim). Pi currently requires Node 22.19+; a known-good command is: +- The historical-name Daimon-org E2E compiles `examples/daimon-org`, which is now an explicit legacy `runtime: pi` fixture. It injects real Codex OAuth into the generated Pi home, runs the generated Pi app twice, and asserts that two agents wrote through a shared workspace resource and recorded/recalled Mneme memory. It does not exercise the public Daimon organization host. **Interim live-model regression check (Slice B), do not delete:** two real Codex agents actually writing to a shared workspace path is unfakeable live-model behavior, kept as-is pending the same compose-and-observe pipeline noted above. Pi currently requires Node 22.19+; a known-good command is: ```bash PATH="$HOME/.nvm/versions/node/v22.22.1/bin:$PATH" \ diff --git a/src/e2e/cliMemory.ts b/src/e2e/cliMemory.ts index c0044a5d..e04aefc6 100644 --- a/src/e2e/cliMemory.ts +++ b/src/e2e/cliMemory.ts @@ -42,9 +42,9 @@ const runMemoryCli = async ( export const runDaimonMemoryRecallCli = async (argv: string[]): Promise => runMemoryCli(argv, { - description: "Run the opt-in Daimon recall compile/probe E2E", + description: "Run the opt-in legacy Pi recall compile/probe E2E (historical command name)", name: "spawnfile-e2e daimon-memory-recall", - resultName: "Daimon memory recall E2E", + resultName: "Legacy Pi memory recall E2E", run: runDaimonMemoryRecallE2E }); diff --git a/src/e2e/cliSmoke.ts b/src/e2e/cliSmoke.ts index d8c1fa6c..f3b99629 100644 --- a/src/e2e/cliSmoke.ts +++ b/src/e2e/cliSmoke.ts @@ -89,7 +89,7 @@ export const runDaimonOrgCli = async (argv: string[]): Promise => { const command = new Command(); command .name("spawnfile-e2e daimon-org") - .description("Run the opt-in Daimon organization E2E against real Codex auth") + .description("Run the opt-in legacy generated-Pi organization E2E against real Codex auth") .option("--codex-auth-path ", "Codex auth.json path") .option("--fixture ", "Fixture directory override") .option("--keep-artifacts", "Keep temporary compile output") @@ -115,7 +115,7 @@ export const runDaimonOrgCli = async (argv: string[]): Promise => { outputDirectory: options.out }); console.log( - `Daimon org E2E passed (${result.mapperNotePath}, ${result.reviewerNotePath}; ` + + `Legacy Pi org E2E passed (${result.mapperNotePath}, ${result.reviewerNotePath}; ` + `memory_events=${result.memoryEventCount} recalled=${result.memoryRecallCount})` ); }; diff --git a/src/e2e/daimonLocalAutonomousCredentials.test.ts b/src/e2e/daimonLocalAutonomousCredentials.test.ts new file mode 100644 index 00000000..a20b825e --- /dev/null +++ b/src/e2e/daimonLocalAutonomousCredentials.test.ts @@ -0,0 +1,53 @@ +import { lstat, mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { removeDirectory } from "../filesystem/index.js"; +import { + cleanupDaimonLocalAutonomousCredentials, + createDaimonLocalAutonomousCredentials +} from "./daimonLocalAutonomousCredentials.js"; + +const homeDirectories: string[] = []; +const credentialDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(credentialDirectories.splice(0).map((directory) => cleanupDaimonLocalAutonomousCredentials(directory))); + await Promise.all(homeDirectories.splice(0).map((directory) => removeDirectory(directory))); +}); + +describe("Daimon local autonomous credentials", () => { + it("creates nonempty exact-0600 fake sources beneath the real home directory", async () => { + const credentials = await createDaimonLocalAutonomousCredentials(); + credentialDirectories.push(credentials.directory); + + expect(path.dirname(credentials.directory)).toBe(os.homedir()); + expect(path.basename(credentials.directory)).toMatch(/^\.spawnfile-daimon-local-autonomous-credentials-/u); + expect((await lstat(credentials.directory)).mode & 0o777).toBe(0o700); + expect(Object.keys(credentials.environment).sort()).toEqual([ + "SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET", + "SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH", + "SPAWNFILE_DAIMON_SOURCE_GROK_AUTH" + ]); + for (const source of Object.values(credentials.environment)) { + const metadata = await lstat(source); + expect(path.dirname(source)).toBe(credentials.directory); + expect(metadata.isFile()).toBe(true); + expect(metadata.size).toBeGreaterThan(0); + expect(metadata.mode & 0o777).toBe(0o600); + } + }); + + it("removes only its credential directory", async () => { + const homeDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-credential-home-")); + homeDirectories.push(homeDirectory); + const credentials = await createDaimonLocalAutonomousCredentials(homeDirectory); + + await cleanupDaimonLocalAutonomousCredentials(credentials.directory); + + await expect(lstat(credentials.directory)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await lstat(homeDirectory)).isDirectory()).toBe(true); + }); +}); diff --git a/src/e2e/daimonLocalAutonomousCredentials.ts b/src/e2e/daimonLocalAutonomousCredentials.ts new file mode 100644 index 00000000..be92e960 --- /dev/null +++ b/src/e2e/daimonLocalAutonomousCredentials.ts @@ -0,0 +1,43 @@ +import { chmod, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { removeDirectory } from "../filesystem/index.js"; + +const PORTABLE_AUTH_ENGINES = ["codex", "grok"] as const; + +export interface DaimonLocalAutonomousCredentials { + directory: string; + environment: Record; +} + +const sourceEnvironment = (engine: (typeof PORTABLE_AUTH_ENGINES)[number]): string => + `SPAWNFILE_DAIMON_SOURCE_${engine.toUpperCase()}_AUTH`; + +export const createDaimonLocalAutonomousCredentials = async ( + homeDirectory = os.homedir() +): Promise => { + const directory = await mkdtemp(path.join(homeDirectory, ".spawnfile-daimon-local-autonomous-credentials-")); + try { + await chmod(directory, 0o700); + const environment: Record = {}; + for (const engine of PORTABLE_AUTH_ENGINES) { + const file = path.join(directory, `${engine}.json`); + await writeFile(file, JSON.stringify({ tokens: { access_token: `fixture-${engine}-access`, refresh_token: `fixture-${engine}-refresh` } }), { mode: 0o600 }); + await chmod(file, 0o600); + environment[sourceEnvironment(engine)] = file; + } + const unlock = path.join(directory, "agy-unlock"); + await writeFile(unlock, "fixture-opaque-unlock", { mode: 0o600 }); + await chmod(unlock, 0o600); + environment.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = unlock; + return { directory, environment }; + } catch (error) { + await removeDirectory(directory).catch(() => undefined); + throw error; + } +}; + +export const cleanupDaimonLocalAutonomousCredentials = async (directory: string): Promise => { + await removeDirectory(directory); +}; diff --git a/src/e2e/daimonRuntimeInstanceLookup.test.ts b/src/e2e/daimonRuntimeInstanceLookup.test.ts index f499ce91..138b196d 100644 --- a/src/e2e/daimonRuntimeInstanceLookup.test.ts +++ b/src/e2e/daimonRuntimeInstanceLookup.test.ts @@ -27,12 +27,12 @@ const buildInstance = (runtime: string): ContainerRuntimeInstanceReport => ({ }); describe("findDaimonRuntimeInstance", () => { - it("finds the instance labeled with the current 'daimon' runtime", () => { + it("does not accept a public Daimon organization host", () => { const instances = [buildInstance("moltnet"), buildInstance("daimon")]; - expect(findDaimonRuntimeInstance(instances)).toBe(instances[1]); + expect(findDaimonRuntimeInstance(instances)).toBeUndefined(); }); - it("finds the instance labeled with the legacy 'pi' runtime", () => { + it("finds the legacy generated-Pi instance", () => { const instances = [buildInstance("moltnet"), buildInstance("pi")]; expect(findDaimonRuntimeInstance(instances)).toBe(instances[1]); }); @@ -47,17 +47,7 @@ describe("findDaimonRuntimeInstance", () => { }); describe("resolveDaimonRuntimeRoot", () => { - it("prefers the current 'daimon' runtime-installs directory when it exists", async () => { - const rootfs = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-runtime-root-test-")); - cleanupDirs.push(rootfs); - await mkdir(path.join(rootfs, "opt", "spawnfile", "runtime-installs", "daimon"), { recursive: true }); - - expect(await resolveDaimonRuntimeRoot(rootfs)).toBe( - path.join(rootfs, "opt", "spawnfile", "runtime-installs", "daimon") - ); - }); - - it("falls back to the legacy 'pi' runtime-installs directory when 'daimon' is absent", async () => { + it("resolves the legacy generated-Pi runtime install directory", async () => { const rootfs = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-runtime-root-test-")); cleanupDirs.push(rootfs); await mkdir(path.join(rootfs, "opt", "spawnfile", "runtime-installs", "pi"), { recursive: true }); diff --git a/src/e2e/daimonRuntimeInstanceLookup.ts b/src/e2e/daimonRuntimeInstanceLookup.ts index 0e5f172c..9b1d8ba4 100644 --- a/src/e2e/daimonRuntimeInstanceLookup.ts +++ b/src/e2e/daimonRuntimeInstanceLookup.ts @@ -1,16 +1,13 @@ import type { ContainerRuntimeInstanceReport } from "../report/index.js"; -import { fileExists } from "../filesystem/index.js"; import { toRootfsPath } from "./runtimeRootfsPaths.js"; -/** Runtime labels a generated Pi/Daimon app's container-level instance may - * report: some compiler report versions label it "pi" (the underlying - * adapter), others "daimon" (the orchestrating runtime). */ -const DAIMON_RUNTIME_LABELS = ["daimon", "pi"] as const; +/** Legacy generated-Pi E2Es are intentionally distinct from the public + * Daimon organization host and therefore accept only Pi instances. */ +const DAIMON_RUNTIME_LABELS = ["pi"] as const; /** - * Find the generated Pi/Daimon runtime instance in a compiled container - * report, tolerating both the current "daimon" runtime label and the legacy - * "pi" label. Shared by every generated Pi/Daimon-app E2E harness. + * Find the legacy generated-Pi runtime instance in a compiled container + * report. Shared by generated-Pi E2E harnesses only. */ export const findDaimonRuntimeInstance = ( runtimeInstances: readonly ContainerRuntimeInstanceReport[] | undefined @@ -20,14 +17,10 @@ export const findDaimonRuntimeInstance = ( ); /** - * Resolve the generated Pi/Daimon runtime install directory under a - * compiled container's rootfs, tolerating both the current "daimon" - * install-dir name and the legacy "pi" name. Shared by every generated - * Pi/Daimon-app E2E harness. + * Resolve the generated-Pi runtime install directory under a compiled + * container's rootfs. Public Daimon organization hosts are never accepted by + * this legacy app harness. */ export const resolveDaimonRuntimeRoot = async (rootfs: string): Promise => { - const daimonRoot = toRootfsPath(rootfs, "/opt/spawnfile/runtime-installs/daimon"); - return (await fileExists(daimonRoot)) - ? daimonRoot - : toRootfsPath(rootfs, "/opt/spawnfile/runtime-installs/pi"); + return toRootfsPath(rootfs, "/opt/spawnfile/runtime-installs/pi"); }; diff --git a/src/e2e/memoryIntegration.ts b/src/e2e/memoryIntegration.ts index cf74bfaa..ca362ab7 100644 --- a/src/e2e/memoryIntegration.ts +++ b/src/e2e/memoryIntegration.ts @@ -31,9 +31,9 @@ export const runDaimonMemoryRecallE2E = async ( options, async ({ compileResult, plan, outputDirectory }) => { const fixtureDirectory = options.fixtureDirectory ?? MIXED_RUNTIME_FIXTURE; - const daimonNodes = getRuntimeNodes(compileResult.report, "daimon"); - if (daimonNodes.length === 0) { - throw new SpawnfileError("runtime_error", "Mixed fixture did not compile a Daimon runtime instance"); + const piNodes = getRuntimeNodes(compileResult.report, "pi"); + if (piNodes.length === 0) { + throw new SpawnfileError("runtime_error", "Mixed fixture did not compile a legacy Pi runtime instance"); } const localist = compileResult.report.nodes.find( @@ -43,7 +43,7 @@ export const runDaimonMemoryRecallE2E = async ( return createUnsupported( fixtureDirectory, outputDirectory, - "Daimon recall fixture path changed; localist agent was not found in compile report", + "Legacy Pi recall fixture path changed; localist agent was not found in compile report", ["Expected localist agent source to be discoverable in report.nodes."] ); } @@ -63,13 +63,13 @@ export const runDaimonMemoryRecallE2E = async ( expectRoomMembers(compileResult.report.container?.moltnet, "mixed_lab", "floor", ["conductor", "analyst", "localist"]); const coverage = collectMemoryCoverageByRuntime(plan, compileResult.report.nodes); - const floorBanks = coverage.get("daimon"); + const floorBanks = coverage.get("pi"); if (!floorBanks || floorBanks.size === 0) { return createUnsupported( fixtureDirectory, outputDirectory, - "Mixed fixture memory plan does not resolve Daimon memory access", - ["BuildCompilePlan resolved memory declarations, but no agent access mapped to daimon runtime."] + "Mixed fixture memory plan does not resolve legacy Pi memory access", + ["BuildCompilePlan resolved memory declarations, but no agent access mapped to pi runtime."] ); } @@ -77,10 +77,10 @@ export const runDaimonMemoryRecallE2E = async ( return createUnsupported( fixtureDirectory, outputDirectory, - "Daimon runtime did not emit expected memory capabilities", + "Legacy Pi runtime did not emit expected memory capabilities", [ `localist has ${memoryBanks.length} declared memory bank(s) for recall checks`, - "Check Daimon direct Mneme memory wiring and compile capability emission." + "Check legacy Pi direct Mneme memory wiring and compile capability emission." ] ); } @@ -88,7 +88,7 @@ export const runDaimonMemoryRecallE2E = async ( return createPassed( fixtureDirectory, outputDirectory, - "Daimon memory wiring is present and executable in compile output", + "Legacy Pi memory wiring is present and executable in compile output", [`localist has ${memoryBanks.length} active memory bank mapping(s).`] ); } @@ -107,7 +107,7 @@ export const runMixedRuntimeMemoryWiringE2E = async ( const floorInstanceRuntimes = (compileResult.report.container?.runtime_instances ?? []) .map((instance) => instance.runtime) .sort(); - const requiredRuntimes = ["daimon", "openclaw", "picoclaw"]; + const requiredRuntimes = ["openclaw", "pi", "picoclaw"]; const missingRuntimes = requiredRuntimes.filter((runtime) => !floorInstanceRuntimes.includes(runtime)); if (missingRuntimes.length > 0) { throw new SpawnfileError( @@ -141,7 +141,7 @@ export const runMixedRuntimeMemoryWiringE2E = async ( "Mixed runtime memory capabilities are missing from compile output", [ `Missing memory capability runtime(s): ${missingCapabilityRuntimes.join(", ")}`, - "Daimon and PicoClaw should emit memory capabilities; OpenClaw should emit memory capabilities through Mneme MCP." + "Pi and PicoClaw should emit memory capabilities; OpenClaw should emit memory capabilities through Mneme MCP." ] ); } @@ -179,7 +179,7 @@ export const runMixedRuntimeMemoryWiringE2E = async ( return createPassed( fixtureDirectory, outputDirectory, - "Mixed-runtime Daimon + PicoClaw + Moltnet memory wiring is present with explicit runtime outcomes", + "Mixed-runtime Pi + PicoClaw + Moltnet memory wiring is present with explicit runtime outcomes", [ "mixed_lab floor room has all declared members", "openclaw/picoclaw dream cron jobs are emitted", @@ -198,10 +198,10 @@ export const runJungianSelfOrgE2E = async ( options, async ({ compileResult, plan, outputDirectory }) => { const fixtureDirectory = options.fixtureDirectory ?? JUNGIAN_FIXTURE; - const daimonNodes = getRuntimeNodes(compileResult.report, "pi") + const piNodes = getRuntimeNodes(compileResult.report, "pi") .filter((node) => node.kind === "agent"); - if (daimonNodes.length === 0) { - throw new SpawnfileError("runtime_error", "Jungian fixture did not compile a Daimon runtime instance"); + if (piNodes.length === 0) { + throw new SpawnfileError("runtime_error", "Jungian fixture did not compile a legacy Pi runtime instance"); } const roomIssues = [ @@ -231,8 +231,8 @@ export const runJungianSelfOrgE2E = async ( } const coverage = collectMemoryCoverageByRuntime(plan, compileResult.report.nodes); - const daimonCoverage = coverage.get("pi"); - if (!daimonCoverage || daimonCoverage.size === 0) { + const piCoverage = coverage.get("pi"); + if (!piCoverage || piCoverage.size === 0) { return createSkipped( fixtureDirectory, outputDirectory, @@ -241,15 +241,15 @@ export const runJungianSelfOrgE2E = async ( ); } - const missingDaimonCapability = !daimonNodes.every(nodeHasMemoryCapability); - if (missingDaimonCapability) { + const missingPiCapability = !piNodes.every(nodeHasMemoryCapability); + if (missingPiCapability) { return createUnsupported( fixtureDirectory, outputDirectory, - "Daimon runtime did not expose memory capabilities for Jungian self-org agents", + "Legacy Pi runtime did not expose memory capabilities for Jungian self-org agents", [ - `Resolved ${daimonCoverage.size} Jungian memory bank mapping(s)`, - "Check Daimon direct Mneme memory wiring and compile capability emission." + `Resolved ${piCoverage.size} Jungian memory bank mapping(s)`, + "Check legacy Pi direct Mneme memory wiring and compile capability emission." ] ); } @@ -259,7 +259,7 @@ export const runJungianSelfOrgE2E = async ( outputDirectory, "Jungian self-org fixture compiles with requested room topology and memory mapping", [ - `Jungian Daimon coverage includes ${daimonCoverage.size} memory bank binding(s)`, + `Jungian legacy Pi coverage includes ${piCoverage.size} memory bank binding(s)`, "nested council rooms are present on mixed Moltnet topology" ] ); diff --git a/src/e2e/scenarios.test.ts b/src/e2e/scenarios.test.ts index bb7789dd..9d96380b 100644 --- a/src/e2e/scenarios.test.ts +++ b/src/e2e/scenarios.test.ts @@ -14,8 +14,8 @@ describe("listDockerAuthE2EScenarios", () => { "picoclaw-api_key", "picoclaw-codex", "picoclaw-claude-code", - "daimon-codex", - "daimon-api_key", + "pi-codex", + "pi-api_key", "team-multi-runtime" ]); }); @@ -31,6 +31,6 @@ describe("filterDockerAuthE2EScenarios", () => { it("filters by auth method", () => { expect( filterDockerAuthE2EScenarios({ authMethods: ["api_key"] }).map((scenario) => scenario.id) - ).toEqual(["openclaw-api_key", "picoclaw-api_key", "daimon-api_key", "team-multi-runtime"]); + ).toEqual(["openclaw-api_key", "picoclaw-api_key", "pi-api_key", "team-multi-runtime"]); }); }); diff --git a/src/e2e/scenarios.ts b/src/e2e/scenarios.ts index 61639d34..912eb1a4 100644 --- a/src/e2e/scenarios.ts +++ b/src/e2e/scenarios.ts @@ -40,8 +40,8 @@ const SINGLE_AGENT_SCENARIOS: DockerAuthE2EScenario[] = [ createSingleAgentScenario("picoclaw", "openai", "gpt-5", "api_key"), createSingleAgentScenario("picoclaw", "openai", "gpt-5", "codex"), createSingleAgentScenario("picoclaw", "anthropic", "claude-sonnet-4-5", "claude-code"), - createSingleAgentScenario("daimon", "openai", "gpt-5.4-mini", "codex"), - createSingleAgentScenario("daimon", "openai", "gpt-5", "api_key") + createSingleAgentScenario("pi", "openai", "gpt-5.4-mini", "codex"), + createSingleAgentScenario("pi", "openai", "gpt-5", "api_key") ]; const TEAM_SCENARIOS: DockerAuthE2EScenario[] = [ diff --git a/src/e2e/types.ts b/src/e2e/types.ts index 41cee349..70ff745d 100644 --- a/src/e2e/types.ts +++ b/src/e2e/types.ts @@ -1,6 +1,6 @@ import type { ModelAuthMethod } from "../shared/index.js"; -export type E2ERuntime = "openclaw" | "picoclaw" | "daimon"; +export type E2ERuntime = "openclaw" | "picoclaw" | "pi" | "daimon"; export type E2EFixtureKind = "docker-auth-agent" | "docker-auth-team"; export type E2EScenarioKind = "single-agent" | "team"; diff --git a/src/evidenceExportHelper/AGENTS.md b/src/evidenceExportHelper/AGENTS.md new file mode 100644 index 00000000..93fb7bc2 --- /dev/null +++ b/src/evidenceExportHelper/AGENTS.md @@ -0,0 +1,38 @@ +# Local Evidence-Export Helper Guide + +This folder owns Spawnfile's package-shipped local-development evidence-export +helper. It is a target setup facility, not a target-resource operation. + +## Structure + +- `helperProgram.mjs` is the image entrypoint source. It reads only the fixed + `/spawnfile/evidence` mount and emits strict canonical USTAR to stdout. +- `copyAssets.mjs` copies that source beside the compiled modules for npm + packaging. +- `recipe.ts` loads the shipped source, creates the fixed Dockerfile and + canonical build context, and derives source identities. +- `preparedAuthority.ts` is the Spawnfile-home, fsynced private reservation + journal for the opaque prepared-helper receipt. It is never a caller path. +- `preparedBuilder.ts` captures the immutable config ID emitted by its own + package-asset build, then re-attests that ID without registry manifests, + RepoDigests, or mutable tag adoption. +- `index.ts` is the folder barrel. + +## Rules + +- Never select an implicit or remote Docker context. +- Never push to or pull from a registry. The reviewed base image must already + exist on the selected local daemon. +- Never adopt an image from a tag alone. Uncompleted reservations always build + afresh and capture Docker's immutable build result; reuse requires an exact + private completion plus immutable image/config inspection. +- The image declares exactly the fixed nonsecret + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`; null, + duplicated, additional, or drifted environment entries fail re-attestation. +- Public callers receive only a versioned receipt and opaque handle. Context, + daemon, base, recipe, reservation detail, and image config identities remain + in the Spawnfile-owned private record. +- Keep stdout machine-readable. Helper failure diagnostics must not expose + evidence paths or contents. +- The emitted evidence archive must remain byte-for-byte compatible with the + strict parser in `../target/evidenceExportArchive.ts`. diff --git a/src/evidenceExportHelper/CLAUDE.md b/src/evidenceExportHelper/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/evidenceExportHelper/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/evidenceExportHelper/boundedExecutor.ts b/src/evidenceExportHelper/boundedExecutor.ts new file mode 100644 index 00000000..465dce14 --- /dev/null +++ b/src/evidenceExportHelper/boundedExecutor.ts @@ -0,0 +1,38 @@ +import { + DockerArtifactProviderError, + type DockerArtifactExecutor, +} from "../target/dockerArtifactsProvider.js"; +import { + createBoundedDockerTargetExecFile, + DockerTargetCommandFailure, +} from "../target/dockerTargetExecFile.js"; + +const missingExactImage = (args: readonly string[], error: unknown): boolean => { + if (!(error instanceof DockerTargetCommandFailure)) return false; + const command = args[0] === "--context" ? args.slice(2) : args; + if (command.length !== 5 || command[0] !== "image" || command[1] !== "inspect" + || command[3] !== "--format") return false; + const match = /^(?:Error response from daemon: )?No such image: ([A-Za-z0-9_.:@/-]+)$/u + .exec(error.stderr.trim()); + return match?.[1] === command[2]; +}; + +/** Tree-safe private Docker executor used at every local-helper boundary. */ +export const createPreparedEvidenceHelperExecutor = ( + dockerCommand: string, +): DockerArtifactExecutor => { + const execute = createBoundedDockerTargetExecFile(); + return async (file, args, options) => { + if (file !== "docker") throw new Error("Prepared evidence-export helper failed"); + try { + return await execute(dockerCommand, args, { + signal: options.signal, + stdin: (options as { readonly stdin?: Uint8Array }).stdin, + timeout: options.timeout, + }); + } catch (error) { + if (missingExactImage(args, error)) throw new DockerArtifactProviderError("image_not_found"); + throw error; + } + }; +}; diff --git a/src/evidenceExportHelper/copyAssets.mjs b/src/evidenceExportHelper/copyAssets.mjs new file mode 100644 index 00000000..c4ecf0e2 --- /dev/null +++ b/src/evidenceExportHelper/copyAssets.mjs @@ -0,0 +1,8 @@ +import { copyFile, mkdir } from "node:fs/promises"; + +const destination = new URL("../../dist/evidenceExportHelper/", import.meta.url); +await mkdir(destination, { recursive: true }); +await copyFile( + new URL("./helperProgram.mjs", import.meta.url), + new URL("helperProgram.mjs", destination), +); diff --git a/src/evidenceExportHelper/helperProgram.mjs b/src/evidenceExportHelper/helperProgram.mjs new file mode 100644 index 00000000..e375c495 --- /dev/null +++ b/src/evidenceExportHelper/helperProgram.mjs @@ -0,0 +1,156 @@ +#!/usr/local/bin/node + +import { constants } from "node:fs"; +import { lstat, open, opendir } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = "/spawnfile/evidence"; +const BLOCK = 512; +const MAX_BYTES = 67_108_864; +const MAX_ENTRIES = 10_000; +const MAX_DEPTH = 32; +const MAX_PATH_BYTES = 255; + +const fail = () => { throw new Error("Evidence export helper failed"); }; +const pad = (size) => Math.ceil(size / BLOCK) * BLOCK; +const same = (left, right) => left.dev === right.dev && left.ino === right.ino + && left.size === right.size && left.mode === right.mode; + +const safePath = (value) => { + const bytes = Buffer.from(value, "utf8"); + if (value.length < 1 || bytes.toString("utf8") !== value + || bytes.byteLength > MAX_PATH_BYTES || value.startsWith("/") + || value.includes("\\") || value.includes("\0") || value.includes("//")) fail(); + const parts = value.split("/"); + if (parts.length > MAX_DEPTH || parts.some((part) => part === "" || part === "." || part === "..")) fail(); + for (const part of parts) { + for (let index = 0; index < part.length; index += 1) { + const code = part.charCodeAt(index); + if (code < 0x20 || code === 0x7f) fail(); + } + } + return value; +}; + +const comparePaths = (left, right) => Buffer.compare( + Buffer.from(left.path, "utf8"), + Buffer.from(right.path, "utf8"), +); + +const collect = async (directory, prefix, entries) => { + const before = await lstat(directory); + if (!before.isDirectory() || before.isSymbolicLink()) fail(); + const handle = await opendir(directory); + const names = []; + try { + for await (const item of handle) names.push(item.name); + } finally { + await handle.close().catch(() => undefined); + } + names.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + for (const name of names) { + const relative = safePath(prefix ? `${prefix}/${name}` : name); + const absolute = path.join(directory, name); + const info = await lstat(absolute); + if (info.isSymbolicLink()) fail(); + if (info.isDirectory()) { + entries.push({ info, path: relative, type: "directory" }); + await collect(absolute, relative, entries); + } else if (info.isFile()) { + entries.push({ info, path: relative, type: "file" }); + } else fail(); + if (entries.length > MAX_ENTRIES) fail(); + } + const after = await lstat(directory); + if (!same(before, after)) fail(); +}; + +const octal = (value, width) => { + const raw = value.toString(8); + if (!Number.isSafeInteger(value) || value < 0 || raw.length > width - 1) fail(); + return Buffer.from(`${raw.padStart(width - 1, "0")}\0`, "ascii"); +}; + +const storedPath = (entry) => entry.type === "directory" ? `${entry.path}/` : entry.path; +const splitPath = (stored) => { + const bytes = Buffer.from(stored, "utf8"); + if (bytes.byteLength <= 100) return { name: bytes, prefix: Buffer.alloc(0) }; + for (let split = stored.lastIndexOf("/"); split > 0; split = stored.lastIndexOf("/", split - 1)) { + const prefix = Buffer.from(stored.slice(0, split), "utf8"); + const name = Buffer.from(stored.slice(split + 1), "utf8"); + if (prefix.byteLength <= 155 && name.byteLength > 0 && name.byteLength <= 100) { + return { name, prefix }; + } + } + return fail(); +}; + +const header = (entry) => { + const stored = storedPath(entry); + const { name, prefix } = splitPath(stored); + const output = Buffer.alloc(BLOCK); + output.set(name, 0); + output.set(octal(entry.type === "directory" ? 0o755 : 0o644, 8), 100); + output.set(octal(0, 8), 108); + output.set(octal(0, 8), 116); + output.set(octal(entry.type === "file" ? entry.info.size : 0, 12), 124); + output.set(octal(0, 12), 136); + output[156] = entry.type === "directory" ? 53 : 48; + output.set(Buffer.from("ustar\0", "ascii"), 257); + output.set(Buffer.from("00", "ascii"), 263); + output.set(octal(0, 8), 329); + output.set(octal(0, 8), 337); + output.set(prefix, 345); + output.fill(0x20, 148, 156); + let sum = 0; + for (const byte of output) sum += byte; + if (sum > 0o777777) fail(); + output.set(Buffer.from(`${sum.toString(8).padStart(6, "0")}\0 `, "ascii"), 148); + return output; +}; + +const write = async (bytes) => { + if (process.stdout.write(bytes)) return; + await new Promise((resolve, reject) => { + process.stdout.once("drain", resolve); + process.stdout.once("error", reject); + }); +}; + +const main = async () => { + const root = await lstat(ROOT); + if (!root.isDirectory() || root.isSymbolicLink()) fail(); + const entries = []; + await collect(ROOT, "", entries); + entries.sort(comparePaths); + if (entries.length < 1) fail(); + let total = BLOCK * 2; + for (const entry of entries) { + total += BLOCK + (entry.type === "file" ? pad(entry.info.size) : 0); + if (!Number.isSafeInteger(total) || total > MAX_BYTES) fail(); + } + for (const entry of entries) { + await write(header(entry)); + if (entry.type !== "file") continue; + const absolute = path.join(ROOT, entry.path); + const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const before = await file.stat(); + if (!before.isFile() || !same(before, entry.info)) fail(); + const bytes = await file.readFile(); + const after = await file.stat(); + if (!same(before, after) || bytes.byteLength !== before.size) fail(); + await write(bytes); + const padding = pad(bytes.byteLength) - bytes.byteLength; + if (padding > 0) await write(Buffer.alloc(padding)); + } finally { + await file.close().catch(() => undefined); + } + } + await write(Buffer.alloc(BLOCK * 2)); +}; + +main().catch(() => { + process.stderr.write("Evidence export helper failed\n"); + process.exitCode = 1; +}); diff --git a/src/evidenceExportHelper/index.ts b/src/evidenceExportHelper/index.ts new file mode 100644 index 00000000..25eb8923 --- /dev/null +++ b/src/evidenceExportHelper/index.ts @@ -0,0 +1,4 @@ +export { createPreparedEvidenceHelperExecutor } from "./boundedExecutor.js"; +export * from "./preparedAuthority.js"; +export * from "./preparedBuilder.js"; +export * from "./recipe.js"; diff --git a/src/evidenceExportHelper/preparedAuthority.test.ts b/src/evidenceExportHelper/preparedAuthority.test.ts new file mode 100644 index 00000000..fe1a6cf8 --- /dev/null +++ b/src/evidenceExportHelper/preparedAuthority.test.ts @@ -0,0 +1,98 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + createPreparedEvidenceHelperKey, + initializePreparedEvidenceHelperAuthorityStore, + newPreparedEvidenceHelperCompletionRecord, + newPreparedEvidenceHelperPendingRecord, + parsePreparedEvidenceHelperReceipt, +} from "./preparedAuthority.js"; + +const roots: string[] = []; +const digest = (value: string): `sha256:${string}` => `sha256:${value.repeat(64)}`; +const facts = Object.freeze({ + base_config_digest: digest("a"), + base_image: "node:22-bookworm-slim", + context: "local_dev", + daemon_digest: digest("b"), + endpoint_digest: digest("c"), + platform: Object.freeze({ architecture: "arm64" as const, os: "linux" as const }), + recipe_digest: digest("d"), +}); +const key = createPreparedEvidenceHelperKey({ + baseConfigDigest: facts.base_config_digest, + context: facts.context, + daemonDigest: facts.daemon_digest, + endpointDigest: facts.endpoint_digest, + platform: facts.platform, + recipeDigest: facts.recipe_digest, +}); + +const fixture = async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "prepared-authority-"))); + roots.push(root); + const privateRoot = path.join(root, "state"); + const pending = newPreparedEvidenceHelperPendingRecord(facts); + return { pending, privateRoot }; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("prepared evidence helper authority", () => { + it("converges independently opened stores on one deterministic fsynced reservation", async () => { + const value = await fixture(); + const stores = await Promise.all(Array.from({ length: 8 }, () => + initializePreparedEvidenceHelperAuthorityStore(value.privateRoot))); + const reservations = await Promise.all(stores.map((store) => store.reserve(key, value.pending))); + expect(reservations).toEqual(Array.from({ length: 8 }, () => value.pending)); + const completion = newPreparedEvidenceHelperCompletionRecord(value.pending, digest("e")); + const completions = await Promise.all(stores.map((store) => store.complete(key, completion))); + expect(completions).toEqual(Array.from({ length: 8 }, () => completion)); + expect(await readdir(value.privateRoot)).toEqual([`${key}.complete.json`, `${key}.pending.json`]); + await expect(stat(path.join(value.privateRoot, `${key}.pending.json`))) + .resolves.toMatchObject({ nlink: 1 }); + await expect(stat(path.join(value.privateRoot, `${key}.complete.json`))) + .resolves.toMatchObject({ nlink: 1 }); + }); + + it("makes the public opaque receipt change with the accepted config identity", async () => { + const value = await fixture(); + const accepted = newPreparedEvidenceHelperCompletionRecord(value.pending, digest("e")); + const changed = newPreparedEvidenceHelperCompletionRecord(value.pending, digest("f")); + expect(changed.receipt.handle).not.toBe(accepted.receipt.handle); + expect(changed.receipt.digest).not.toBe(accepted.receipt.digest); + expect(changed.accepted_image_config_digest).toBe(digest("f")); + }); + + it("rejects a conflicting pending record rather than adopting a nearby base tag", async () => { + const value = await fixture(); + const store = await initializePreparedEvidenceHelperAuthorityStore(value.privateRoot); + await store.reserve(key, value.pending); + await expect(store.reserve(key, { ...value.pending, base_image: "node:22-alpine" })) + .rejects.toThrow("Prepared evidence-export helper failed"); + }); + + it("rejects accessor and proxy public receipts without reading them", () => { + let reads = 0; + const accessor = { + handle: `opaque_${"a".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", + } as Record; + Object.defineProperty(accessor, "digest", { + enumerable: true, + get: () => { reads += 1; return digest("b"); }, + }); + expect(() => parsePreparedEvidenceHelperReceipt(accessor)).toThrow("Prepared evidence-export helper failed"); + expect(() => parsePreparedEvidenceHelperReceipt(new Proxy({ + digest: digest("b"), handle: `opaque_${"a".repeat(64)}`, + version: "spawnfile.target-evidence-export-helper.prepared.v1", + }, {}))).toThrow("Prepared evidence-export helper failed"); + expect(reads).toBe(0); + }); +}); diff --git a/src/evidenceExportHelper/preparedAuthority.ts b/src/evidenceExportHelper/preparedAuthority.ts new file mode 100644 index 00000000..60a3ca21 --- /dev/null +++ b/src/evidenceExportHelper/preparedAuthority.ts @@ -0,0 +1,225 @@ +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { link, lstat, mkdir, open, realpath, unlink } from "node:fs/promises"; +import path from "node:path"; +import { types as nodeTypes } from "node:util"; + +import { parseOpaqueTargetHandle, type OpaqueTargetHandle } from "../target/contracts.js"; + +export const PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION = + "spawnfile.target-evidence-export-helper.prepared.v1" as const; +export const PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION = + "spawnfile.target-evidence-export-helper.private.v3" as const; + +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const HANDLE = /^opaque_[a-f0-9]{64}$/u; +const CONTEXT = /^[a-z][a-z0-9_-]{0,63}$/u; +const ERROR = "Prepared evidence-export helper failed"; +const OWNER = process.getuid?.(); + +export interface PreparedEvidenceHelperReceipt { + readonly digest: `sha256:${string}`; + readonly handle: OpaqueTargetHandle; + readonly version: typeof PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION; +} +export interface PreparedEvidenceHelperPendingRecord { + readonly base_config_digest: `sha256:${string}`; + readonly base_image: string; + readonly context: string; + readonly daemon_digest: `sha256:${string}`; + readonly endpoint_digest: `sha256:${string}`; + readonly platform: { readonly architecture: "amd64" | "arm64"; readonly os: "linux" }; + readonly recipe_digest: `sha256:${string}`; + readonly version: typeof PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION; +} +export interface PreparedEvidenceHelperCompletionRecord { + readonly accepted_image_config_digest: `sha256:${string}`; + readonly pending_digest: `sha256:${string}`; + readonly receipt: PreparedEvidenceHelperReceipt; + readonly version: typeof PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION; +} +export interface PreparedEvidenceHelperAuthority { + readonly completion: PreparedEvidenceHelperCompletionRecord | null; + readonly pending: PreparedEvidenceHelperPendingRecord; +} + +const fail = (): never => { throw new Error(ERROR); }; +const hash = (domain: string, value: string): `sha256:${string}` => + `sha256:${createHash("sha256").update(`spawnfile.evidence-helper.${domain}.v1\0`).update(value).digest("hex")}`; +const exact = (raw: unknown, keys: readonly string[]): raw is Record => + raw !== null && typeof raw === "object" && !Array.isArray(raw) + && !nodeTypes.isProxy(raw) && Object.getPrototypeOf(raw) === Object.prototype + && Reflect.ownKeys(raw).every((key) => typeof key === "string") + && Object.keys(raw).sort().join("\0") === [...keys].sort().join("\0") + && Object.values(Object.getOwnPropertyDescriptors(raw)).every((item) => + item.enumerable && "value" in item); +const digest = (raw: unknown): `sha256:${string}` => + typeof raw === "string" && DIGEST.test(raw) ? raw as `sha256:${string}` : fail(); +const text = (raw: unknown, maximum: number): string => + typeof raw === "string" && raw === raw.trim() && !raw.includes("\0") + && Buffer.byteLength(raw, "utf8") > 0 && Buffer.byteLength(raw, "utf8") <= maximum ? raw : fail(); + +export const parsePreparedEvidenceHelperReceipt = (raw: unknown): PreparedEvidenceHelperReceipt => { + if (!exact(raw, ["digest", "handle", "version"]) + || raw.version !== PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION + || typeof raw.handle !== "string" || !HANDLE.test(raw.handle)) return fail(); + return Object.freeze({ digest: digest(raw.digest), handle: parseOpaqueTargetHandle(raw.handle), + version: PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION }); +}; +export const createPreparedEvidenceHelperReceiptBytes = (raw: unknown): string => + JSON.stringify(parsePreparedEvidenceHelperReceipt(raw)); +export const pendingDigest = (raw: unknown): `sha256:${string}` => + hash("pending", createPreparedEvidenceHelperPendingBytes(raw)); +export const createPreparedEvidenceHelperReceipt = (input: { + readonly configDigest: unknown; readonly pendingDigest: unknown; +}): PreparedEvidenceHelperReceipt => { + const config = digest(input.configDigest); const pending = digest(input.pendingDigest); + const handle = parseOpaqueTargetHandle(`opaque_${hash("prepared-handle", `${pending}\0${config}`).slice(7)}`); + return Object.freeze({ digest: hash("prepared-receipt", `${PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION}\0${handle}\0${pending}\0${config}`), + handle, version: PREPARED_EVIDENCE_HELPER_RECEIPT_VERSION }); +}; + +export const parsePreparedEvidenceHelperPendingRecord = ( + raw: unknown, +): PreparedEvidenceHelperPendingRecord => { + if (!exact(raw, ["base_config_digest", "base_image", "context", "daemon_digest", "endpoint_digest", + "platform", "recipe_digest", "version"]) + || raw.version !== PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION + || !exact(raw.platform, ["architecture", "os"]) + || raw.platform.os !== "linux" + || raw.platform.architecture !== "amd64" && raw.platform.architecture !== "arm64") return fail(); + const context = text(raw.context, 64); + if (!CONTEXT.test(context)) return fail(); + return Object.freeze({ base_config_digest: digest(raw.base_config_digest), base_image: text(raw.base_image, 512), + context, daemon_digest: digest(raw.daemon_digest), endpoint_digest: digest(raw.endpoint_digest), + platform: Object.freeze({ architecture: raw.platform.architecture, os: "linux" as const }), + recipe_digest: digest(raw.recipe_digest), + version: PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION }); +}; +export const createPreparedEvidenceHelperPendingBytes = (raw: unknown): string => + JSON.stringify(parsePreparedEvidenceHelperPendingRecord(raw)); +export const parsePreparedEvidenceHelperCompletionRecord = (raw: unknown): PreparedEvidenceHelperCompletionRecord => { + if (!exact(raw, ["accepted_image_config_digest", "pending_digest", "receipt", "version"]) + || raw.version !== PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION) return fail(); + const accepted = digest(raw.accepted_image_config_digest); const pending = digest(raw.pending_digest); + const receipt = parsePreparedEvidenceHelperReceipt(raw.receipt); + const expected = createPreparedEvidenceHelperReceipt({ configDigest: accepted, pendingDigest: pending }); + if (receipt.handle !== expected.handle || receipt.digest !== expected.digest) return fail(); + return Object.freeze({ accepted_image_config_digest: accepted, pending_digest: pending, receipt, + version: PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION }); +}; +export const createPreparedEvidenceHelperCompletionBytes = (raw: unknown): string => + JSON.stringify(parsePreparedEvidenceHelperCompletionRecord(raw)); +export const createPreparedEvidenceHelperKey = (input: { + readonly baseConfigDigest: string; readonly context: string; readonly daemonDigest: string; + readonly endpointDigest: string; readonly platform: { readonly architecture: string; readonly os: string }; + readonly recipeDigest: string; +}): string => hash("private-record", [input.context, input.endpointDigest, input.daemonDigest, + input.platform.os, input.platform.architecture, input.baseConfigDigest, input.recipeDigest].join("\0")).slice(7); +export const newPreparedEvidenceHelperPendingRecord = (input: Omit): PreparedEvidenceHelperPendingRecord => + parsePreparedEvidenceHelperPendingRecord({ ...input, version: PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION }); +export const newPreparedEvidenceHelperCompletionRecord = (pending: unknown, configDigest: unknown): PreparedEvidenceHelperCompletionRecord => { + const pendingRecord = parsePreparedEvidenceHelperPendingRecord(pending); const pendingHash = pendingDigest(pendingRecord); + const accepted = digest(configDigest); + return parsePreparedEvidenceHelperCompletionRecord({ accepted_image_config_digest: accepted, pending_digest: pendingHash, + receipt: createPreparedEvidenceHelperReceipt({ configDigest: accepted, pendingDigest: pendingHash }), + version: PREPARED_EVIDENCE_HELPER_PRIVATE_VERSION }); +}; + +const root = async (raw: unknown): Promise => { + if (typeof raw !== "string" || !path.isAbsolute(raw) || path.normalize(raw) !== raw + || raw === path.parse(raw).root) return fail(); + let existed = true; + try { await lstat(raw); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return fail(); + existed = false; + } + try { await mkdir(raw, { mode: 0o700, recursive: true }); } catch { return fail(); } + const info = await lstat(raw).catch(fail); + if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o777) !== 0o700 + || OWNER !== undefined && info.uid !== OWNER || await realpath(raw).catch(fail) !== raw) return fail(); + await syncRoot(raw); + if (!existed) await syncRoot(path.dirname(raw)); + return raw; +}; +const readFile = async (file: string, links: readonly number[] = [1]): Promise => { + let handle; + try { handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; return fail(); } + try { + const info = await handle.stat(); + if (!info.isFile() || !links.includes(info.nlink) || info.size < 1 || info.size > 16_384 + || (info.mode & 0o777) !== 0o600 || OWNER !== undefined && info.uid !== OWNER) return fail(); + return await handle.readFile({ encoding: "utf8" }); + } catch { return fail(); } finally { await handle?.close().catch(() => undefined); } +}; +const syncRoot = async (directory: string): Promise => { + const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW).catch(fail); + try { await handle.sync(); } finally { await handle.close().catch(() => undefined); } +}; + +/** Immutable, fsynced pending/completion authority records keyed by exact target facts. */ +export class PreparedEvidenceHelperAuthorityStore { + readonly #root: string; + public constructor(privateRoot: string) { this.#root = privateRoot; } + public async load(key: string): Promise { + const pending = await this.#read(key, "pending", parsePreparedEvidenceHelperPendingRecord); + if (!pending) return null; + // A writer fsyncs before and after publication. A racing reader may observe + // the link before that writer reaches its directory fsync, so it must make + // the accepted transaction durable before proceeding to a Docker mutation. + await syncRoot(this.#root); + const completion = await this.#read(key, "complete", parsePreparedEvidenceHelperCompletionRecord); + if (completion) await syncRoot(this.#root); + if (completion && completion.pending_digest !== pendingDigest(pending)) return fail(); + return Object.freeze({ completion, pending }); + } + public async reserve(key: string, raw: unknown): Promise { + const record = parsePreparedEvidenceHelperPendingRecord(raw); + return this.#publish(key, "pending", record, createPreparedEvidenceHelperPendingBytes, + parsePreparedEvidenceHelperPendingRecord); + } + public async complete(key: string, raw: unknown): Promise { + const pending = await this.#read(key, "pending", parsePreparedEvidenceHelperPendingRecord); + if (!pending) return fail(); + const record = parsePreparedEvidenceHelperCompletionRecord(raw); + if (record.pending_digest !== pendingDigest(pending)) return fail(); + return this.#publish(key, "complete", record, createPreparedEvidenceHelperCompletionBytes, + parsePreparedEvidenceHelperCompletionRecord); + } + async #read(key: string, phase: "pending" | "complete", parser: (raw: unknown) => T): Promise { + if (!/^[a-f0-9]{64}$/u.test(key)) return fail(); + const file = path.join(this.#root, `${key}.${phase}.json`); + const complete = await readFile(file, [1, 2]); if (complete === null) return null; + try { return parser(JSON.parse(complete)); } catch { return fail(); } + } + async #publish(key: string, phase: "pending" | "complete", raw: T, + bytesFor: (value: unknown) => string, parser: (value: unknown) => T): Promise { + if (!/^[a-f0-9]{64}$/u.test(key)) return fail(); + const bytes = bytesFor(raw); const existing = await this.#read(key, phase, parser); + if (existing) { + await syncRoot(this.#root); + return bytesFor(existing) === bytes ? existing : fail(); + } + const file = path.join(this.#root, `${key}.${phase}.json`); + const stage = path.join(this.#root, `.${key}.${phase}.${randomBytes(12).toString("hex")}.stage`); + let handle; + try { + handle = await open(stage, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + await handle.writeFile(bytes, "utf8"); await handle.sync(); await handle.close(); handle = undefined; + await syncRoot(this.#root); await link(stage, file); await syncRoot(this.#root); + await unlink(stage); await syncRoot(this.#root); + } catch { + await handle?.close().catch(() => undefined); + const published = await this.#read(key, phase, parser); + if (!published || bytesFor(published) !== bytes) return fail(); + await syncRoot(this.#root); + } finally { await unlink(stage).catch(() => undefined); } + return (await this.#read(key, phase, parser)) ?? fail(); + } +} +export const initializePreparedEvidenceHelperAuthorityStore = async ( + privateRoot: unknown, +): Promise => + new PreparedEvidenceHelperAuthorityStore(await root(privateRoot)); diff --git a/src/evidenceExportHelper/preparedBuilder.test.ts b/src/evidenceExportHelper/preparedBuilder.test.ts new file mode 100644 index 00000000..bd6915da --- /dev/null +++ b/src/evidenceExportHelper/preparedBuilder.test.ts @@ -0,0 +1,232 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + DockerArtifactProviderError, + type DockerArtifactExecutor, +} from "../target/dockerArtifactsProvider.js"; +import { EVIDENCE_EXPORT_HELPER_ENV } from "../target/evidenceExportProvider.js"; + +import { prepareEvidenceExportHelper, resolvePreparedEvidenceHelperImage } from "./preparedBuilder.js"; + +const roots: string[] = []; +const digest = (value: string): `sha256:${string}` => `sha256:${value.repeat(64)}`; +const base = digest("a"); +const helper = digest("b"); +const drift = digest("c"); +interface Image { readonly config: `sha256:${string}`; readonly helper: boolean; } +interface State { + buildOutput?: string; + readonly calls: string[][]; + readonly images: Map; + buildConfig?: `sha256:${string}`; + endpoint?: string; + helperConfig?: Readonly>; + helperCmd?: unknown; + helperEnv?: unknown; + implicitEndpoint?: string; +} + +const helperConfigKeys = [ + "Cmd", "Entrypoint", "Env", "ExposedPorts", "Healthcheck", "Labels", "User", "Volumes", +] as const; + +const docker = (state: State): DockerArtifactExecutor => async (_file, args, options) => { + state.calls.push([...args]); const command = args.slice(2); + if (command[0] === "context") { + const endpoint = command[2] === "local_dev" + ? state.endpoint + : state.implicitEndpoint ?? state.endpoint; + return { stderr: "", stdout: JSON.stringify(endpoint ?? "unix:///tmp/docker.sock") }; + } + if (command[0] === "info") return { stderr: "", stdout: JSON.stringify({ + Architecture: "arm64", DockerRootDir: "/var/lib/docker", OSType: "linux", ServerVersion: "27.0", + }) }; + if (command[0] === "image" && command[1] === "inspect") { + const image = state.images.get(command[2]!); + if (!image) throw new DockerArtifactProviderError("image_not_found"); + if (command[4]!.includes("Config")) { + const configured = state.helperConfig ?? { + Cmd: Object.hasOwn(state, "helperCmd") ? state.helperCmd : [], + Entrypoint: ["/bin/spawnfile-export-helper"], + Env: Object.hasOwn(state, "helperEnv") ? state.helperEnv : EVIDENCE_EXPORT_HELPER_ENV, + ExposedPorts: null, + Healthcheck: null, Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, + User: "65534:65534", Volumes: null, + }; + const projected = Object.fromEntries(helperConfigKeys.map((key) => [ + key, Object.hasOwn(configured, key) ? configured[key] : null, + ])); + return { stderr: "", stdout: JSON.stringify([{ + Architecture: "arm64", Config: image.helper ? projected : {}, Id: image.config, Os: "linux", + }]) }; + } + return { stderr: "", stdout: JSON.stringify([{ Architecture: "arm64", Id: image.config, Os: "linux" }]) }; + } + if (command[0] === "build") { + const produced = state.buildConfig ?? helper; + state.images.set(produced, { config: produced, helper: true }); + expect(options).toMatchObject({ timeout: 120_000 }); + expect(command).toContain("--network=none"); expect(command).toContain("--pull=false"); + expect(command).not.toContain("--tag"); + return { stderr: "", stdout: state.buildOutput ?? `${produced}\n` }; + } + throw new Error(`unexpected ${args.join(" ")}`); +}; +const fixture = async (changes: Partial = {}) => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "prepared-helper-"))); roots.push(root); + const state: State = { calls: [], images: new Map([["node:22-bookworm-slim", { config: base, helper: false }]]), ...changes }; + return { input: { baseImage: "node:22-bookworm-slim", context: "local_dev", executor: docker(state), privateRoot: path.join(root, "state") }, root, state }; +}; +const builds = (state: State): number => state.calls.filter((args) => args.includes("build")).length; +const completionConfig = async (privateRoot: string): Promise => { + const complete = (await readdir(privateRoot)).find((name) => name.endsWith(".complete.json")); + return JSON.parse(await readFile(path.join(privateRoot, complete!), "utf8")).accepted_image_config_digest; +}; +const legacyReservationTag = async (privateRoot: string): Promise => { + const pending = (await readdir(privateRoot)).find((name) => /^[a-f0-9]{64}\.pending\.json$/u.test(name)); + if (!pending) throw new Error("missing test reservation"); + return `spawnfile-local/evidence-export-helper:tx-${pending.slice(0, 32)}`; +}; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); + +describe("Spawnfile-owned prepared evidence helper", () => { + it("binds the receipt to an immutable fsynced accepted config completion", async () => { + const value = await fixture(); const receipt = await prepareEvidenceExportHelper(value.input); + expect(receipt).toEqual({ digest: expect.stringMatching(/^sha256:/u), handle: expect.stringMatching(/^opaque_/u), + version: "spawnfile.target-evidence-export-helper.prepared.v1" }); + const completion = (await readdir(value.input.privateRoot)).find((name) => name.endsWith(".complete.json")); + const stored = JSON.parse(await readFile(path.join(value.input.privateRoot, completion!), "utf8")); + expect(stored.accepted_image_config_digest).toBe(helper); expect(stored.receipt).toEqual(receipt); + expect(await resolvePreparedEvidenceHelperImage(value.input, receipt)).toEqual({ configDigest: helper, imageReference: helper }); + }); + + it("projects every Config key safely while rejecting hostile optional values", async () => { + const required = { + Entrypoint: ["/bin/spawnfile-export-helper"], Env: EVIDENCE_EXPORT_HELPER_ENV, + Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, User: "65534:65534", + }; + const value = await fixture({ helperConfig: required }); + await expect(prepareEvidenceExportHelper(value.input)).resolves.toMatchObject({ + version: "spawnfile.target-evidence-export-helper.prepared.v1", + }); + const format = value.state.calls.find((args) => args[2] === "image" + && args[3] === "inspect" && args[4] === helper && args[5] === "--format")?.[6]; + for (const key of helperConfigKeys) { + expect(format).toContain(`{{json (index .Config "${key}")}}`); + expect(format).not.toContain(`{{json .Config.${key}}}`); + } + const hostile = await fixture({ helperConfig: { ...required, Volumes: { "/secret": {} } } }); + await expect(prepareEvidenceExportHelper(hostile.input)) + .rejects.toThrow("Prepared evidence-export helper failed"); + }); + + it.each([ + ["null", null], + ["empty", []], + ["duplicate", [...EVIDENCE_EXPORT_HELPER_ENV, ...EVIDENCE_EXPORT_HELPER_ENV]], + ["addition", [...EVIDENCE_EXPORT_HELPER_ENV, "HOME=/bad"]], + ["drift", ["PATH=/bad"]], + ["secret", ["TOKEN=private"]], + ])("rejects hostile helper environment projection: %s", async (_name, helperEnv) => { + const value = await fixture({ helperEnv }); + await expect(prepareEvidenceExportHelper(value.input)) + .rejects.toThrow("Prepared evidence-export helper failed"); + expect((await readdir(value.input.privateRoot)).some((name) => name.endsWith(".complete.json"))) + .toBe(false); + }); + + it("converges concurrent identical calls on one deterministic pending reservation and build", async () => { + const value = await fixture(); + const [left, right] = await Promise.all([prepareEvidenceExportHelper(value.input), prepareEvidenceExportHelper(value.input)]); + expect(left).toEqual(right); expect(builds(value.state)).toBe(1); + expect(value.state.calls.find((args) => args.includes("build"))?.slice(2)).not.toContain("--tag"); + }); + + it("rejects completed immutable-config drift without rebuilding it", async () => { + const value = await fixture(); await prepareEvidenceExportHelper(value.input); + value.state.images.set(helper, { config: drift, helper: true }); const prior = builds(value.state); + await expect(prepareEvidenceExportHelper(value.input)).rejects.toThrow("Prepared evidence-export helper failed"); + expect(builds(value.state)).toBe(prior); + }); + + it("rebuilds a missing completed config and rejects a changed rebuilt identity", async () => { + const value = await fixture(); const receipt = await prepareEvidenceExportHelper(value.input); + value.state.images.delete(helper); await expect(prepareEvidenceExportHelper(value.input)).resolves.toEqual(receipt); + value.state.images.delete(helper); value.state.buildConfig = drift; + await expect(prepareEvidenceExportHelper(value.input)).rejects.toThrow("Prepared evidence-export helper failed"); + }); + + it("does not adopt a malicious pretag and records only this build output", async () => { + const value = await fixture(); + await expect(prepareEvidenceExportHelper({ ...value.input, testHooks: { afterReserve: () => { + throw new Error("reserved"); + } } })).rejects.toThrow("reserved"); + const hostile = await legacyReservationTag(value.input.privateRoot); + value.state.images.set(hostile, { config: drift, helper: true }); + await prepareEvidenceExportHelper(value.input); + expect(await completionConfig(value.input.privateRoot)).toBe(helper); + expect(value.state.images.get(hostile)).toEqual({ config: drift, helper: true }); + expect(value.state.calls.some((args) => args[4] === hostile)).toBe(false); + }); + + it("fails closed unless this build emits exactly one immutable config ID", async () => { + const value = await fixture({ buildOutput: `${helper}\n${drift}\n` }); + await expect(prepareEvidenceExportHelper(value.input)).rejects.toThrow("Prepared evidence-export helper failed"); + expect((await readdir(value.input.privateRoot)).some((name) => name.endsWith(".complete.json"))).toBe(false); + }); + + it("does not let a tag swap after build alter completion provenance", async () => { + const value = await fixture(); let hostile = ""; + await prepareEvidenceExportHelper({ ...value.input, testHooks: { afterBuild: async () => { + hostile = await legacyReservationTag(value.input.privateRoot); + value.state.images.set(hostile, { config: drift, helper: true }); + } } }); + expect(await completionConfig(value.input.privateRoot)).toBe(helper); + expect(value.state.images.get(hostile)).toEqual({ config: drift, helper: true }); + expect(value.state.calls.some((args) => args[4] === hostile)).toBe(false); + }); + + it("recovers an interrupted uncompleted build by producing fresh provenance", async () => { + const value = await fixture(); let hostile = ""; + await expect(prepareEvidenceExportHelper({ ...value.input, testHooks: { afterBuild: async () => { + hostile = await legacyReservationTag(value.input.privateRoot); + value.state.images.set(hostile, { config: drift, helper: true }); throw new Error("crash"); + } } })).rejects.toThrow("crash"); + await prepareEvidenceExportHelper(value.input); + expect(builds(value.state)).toBe(2); + expect(await completionConfig(value.input.privateRoot)).toBe(helper); + expect(value.state.calls.some((args) => args[4] === hostile)).toBe(false); + }); + + it("rejects remote contexts before base pulls or image mutations", async () => { + const value = await fixture({ endpoint: "ssh://operator@example.test" }); + await expect(prepareEvidenceExportHelper(value.input)).rejects.toThrow("Prepared evidence-export helper failed"); + expect(value.state.calls.map((args) => args.slice(2, 5))).toEqual([["context", "inspect", "local_dev"]]); + }); + + it("classifies the explicitly requested context rather than an implicit default", async () => { + const value = await fixture({ + endpoint: "ssh://operator@example.test", + implicitEndpoint: "unix:///tmp/default.sock", + }); + await expect(prepareEvidenceExportHelper(value.input)).rejects.toThrow("Prepared evidence-export helper failed"); + expect(value.state.calls).toEqual([[ + "--context", "local_dev", "context", "inspect", "local_dev", "--format", + "{{json .Endpoints.docker.Host}}", + ]]); + }); + + it.each(["beforeReserve", "afterReserve", "beforeBuild", "afterBuild", "beforeComplete", "afterComplete", "beforeReceipt"]) ( + "recovers every durable, mutation, completion, and receipt fault (%s)", async (boundary) => { + const value = await fixture(); + await expect(prepareEvidenceExportHelper({ ...value.input, testHooks: { + [boundary]: () => { throw new Error("injected"); }, + } })).rejects.toThrow(); + await expect(prepareEvidenceExportHelper(value.input)).resolves.toMatchObject({ handle: expect.any(String) }); + } + ); +}); diff --git a/src/evidenceExportHelper/preparedBuilder.ts b/src/evidenceExportHelper/preparedBuilder.ts new file mode 100644 index 00000000..4156fa24 --- /dev/null +++ b/src/evidenceExportHelper/preparedBuilder.ts @@ -0,0 +1,189 @@ +import { createHash } from "node:crypto"; + +import { parseDockerBaseImageReference } from "../target/dockerBaseImage.js"; +import { + DockerArtifactProviderError, +} from "../target/dockerArtifactsProvider.js"; +import { + EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL, + EVIDENCE_EXPORT_HELPER_CONTRACT_VERSION, + EVIDENCE_EXPORT_HELPER_ENTRYPOINT, + EVIDENCE_EXPORT_HELPER_ENV, + EVIDENCE_EXPORT_HELPER_USER, +} from "../target/evidenceExportProvider.js"; + +import { + createPreparedEvidenceHelperKey, + initializePreparedEvidenceHelperAuthorityStore, + newPreparedEvidenceHelperCompletionRecord, + newPreparedEvidenceHelperPendingRecord, + type PreparedEvidenceHelperAuthority, + type PreparedEvidenceHelperPendingRecord, + type PreparedEvidenceHelperReceipt, +} from "./preparedAuthority.js"; +import { loadLocalEvidenceHelperRecipe, type LocalEvidenceHelperRecipe } from "./recipe.js"; +import type { PrepareEvidenceHelperInput } from "./preparedBuilderTypes.js"; + +export type { PrepareEvidenceHelperInput } from "./preparedBuilderTypes.js"; + +const ERROR = "Prepared evidence-export helper failed"; +const CONTEXT = /^[a-z][a-z0-9_-]{0,63}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const BUILD_ID = /^(sha256:[a-f0-9]{64})(?:\r?\n)?$/u; +const MAX_OUTPUT = 65_536; +const BASE_FORMAT = "[{\"Architecture\":{{json .Architecture}},\"Id\":{{json .Id}},\"Os\":{{json .Os}}}]"; +const IMAGE_FORMAT = "[{\"Architecture\":{{json .Architecture}},\"Config\":{\"Cmd\":{{json (index .Config \"Cmd\")}},\"Entrypoint\":{{json (index .Config \"Entrypoint\")}},\"Env\":{{json (index .Config \"Env\")}},\"ExposedPorts\":{{json (index .Config \"ExposedPorts\")}},\"Healthcheck\":{{json (index .Config \"Healthcheck\")}},\"Labels\":{{json (index .Config \"Labels\")}},\"User\":{{json (index .Config \"User\")}},\"Volumes\":{{json (index .Config \"Volumes\")}}},\"Id\":{{json .Id}},\"Os\":{{json .Os}}}]"; +const live = new Map>(); + +interface Facts { + readonly baseConfig: `sha256:${string}`; + readonly daemonDigest: `sha256:${string}`; + readonly endpointDigest: `sha256:${string}`; + readonly platform: { readonly architecture: "amd64" | "arm64"; readonly os: "linux" }; +} + +const fail = (): never => { throw new Error(ERROR); }; +const hash = (domain: string, value: string): `sha256:${string}` => + `sha256:${createHash("sha256").update(`spawnfile.evidence-helper.${domain}.v1\0`).update(value).digest("hex")}`; +const exact = (raw: unknown, keys: readonly string[]): raw is Record => + raw !== null && typeof raw === "object" && !Array.isArray(raw) + && Object.getPrototypeOf(raw) === Object.prototype + && Object.keys(raw).sort().join("\0") === [...keys].sort().join("\0"); +const same = (left: unknown, right: unknown): boolean => JSON.stringify(left) === JSON.stringify(right); +const digest = (raw: unknown): `sha256:${string}` => + typeof raw === "string" && DIGEST.test(raw) ? raw as `sha256:${string}` : fail(); +const timeout = (raw: unknown): number => raw === undefined ? 120_000 + : typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1 && raw <= 120_000 ? raw : fail(); +const parse = (raw: string): unknown => { try { return JSON.parse(raw); } catch { return fail(); } }; +const hook = async (value: (() => Promise | void) | undefined): Promise => { if (value) await value(); }; + +const execution = (input: PrepareEvidenceHelperInput, value: number) => { + if (!CONTEXT.test(input.context) || typeof input.executor !== "function") return fail(); + const run = async (args: string[], stdin?: Uint8Array): Promise => { + const result = await input.executor("docker", ["--context", input.context, ...args], { + ...(input.signal ? { signal: input.signal } : {}), timeout: value, ...(stdin ? { stdin } as never : {}), + } as never); + if (!result || typeof result.stdout !== "string" || typeof result.stderr !== "string" + || Buffer.byteLength(result.stdout, "utf8") > MAX_OUTPUT || Buffer.byteLength(result.stderr, "utf8") > MAX_OUTPUT) return fail(); + return result.stdout; + }; + return Object.freeze({ run }); +}; +const localFacts = async ( + run: ReturnType["run"], + context: string, + baseImage: string, +): Promise => { + const endpoint = parse((await run([ + "context", "inspect", context, "--format", "{{json .Endpoints.docker.Host}}", + ])).trim()); + if (typeof endpoint !== "string" || !/^(?:fd|npipe|unix):\/\/[^\s]+$/u.test(endpoint)) return fail(); + const daemon = parse(await run(["info", "--format", "{\"Architecture\":{{json .Architecture}},\"DockerRootDir\":{{json .DockerRootDir}},\"OSType\":{{json .OSType}},\"ServerVersion\":{{json .ServerVersion}}}"])); + if (!exact(daemon, ["Architecture", "DockerRootDir", "OSType", "ServerVersion"]) + || daemon.OSType !== "linux" || typeof daemon.DockerRootDir !== "string" || typeof daemon.ServerVersion !== "string") return fail(); + const architecture = ["amd64", "x64", "x86_64"].includes(daemon.Architecture as string) ? "amd64" as const + : ["arm64", "aarch64"].includes(daemon.Architecture as string) ? "arm64" as const : fail(); + const image = parse(await run(["image", "inspect", baseImage, "--format", BASE_FORMAT])); + if (!Array.isArray(image) || image.length !== 1 || !exact(image[0], ["Architecture", "Id", "Os"]) + || image[0].Os !== "linux" || image[0].Architecture !== architecture) return fail(); + return Object.freeze({ baseConfig: digest(image[0].Id), daemonDigest: hash("daemon", JSON.stringify(daemon)), + endpointDigest: hash("endpoint", endpoint), platform: Object.freeze({ architecture, os: "linux" as const }) }); +}; +const inspectHelper = async (run: ReturnType["run"], reference: string, facts: Facts): Promise<`sha256:${string}`> => { + const image = parse(await run(["image", "inspect", reference, "--format", IMAGE_FORMAT])); + if (!Array.isArray(image) || image.length !== 1 || !exact(image[0], ["Architecture", "Config", "Id", "Os"]) + || image[0].Architecture !== facts.platform.architecture || image[0].Os !== "linux" + || !exact(image[0].Config, ["Cmd", "Entrypoint", "Env", "ExposedPorts", "Healthcheck", "Labels", "User", "Volumes"])) return fail(); + const config = image[0].Config as Record; + if (!same(config.Entrypoint, EVIDENCE_EXPORT_HELPER_ENTRYPOINT) + || config.Cmd !== null && !same(config.Cmd, []) || !same(config.Env, EVIDENCE_EXPORT_HELPER_ENV) + || config.ExposedPorts !== null + || config.Healthcheck !== null || config.Volumes !== null || config.User !== EVIDENCE_EXPORT_HELPER_USER + || !exact(config.Labels, [EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL]) + || config.Labels[EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL] !== EVIDENCE_EXPORT_HELPER_CONTRACT_VERSION) return fail(); + return digest(image[0].Id); +}; +const inspectMaybe = async (run: ReturnType["run"], reference: string, + facts: Facts): Promise<`sha256:${string}` | null> => { + try { return await inspectHelper(run, reference, facts); } + catch (error) { if (error instanceof DockerArtifactProviderError && error.kind === "image_not_found") return null; throw error; } +}; +const matching = (record: PreparedEvidenceHelperPendingRecord, baseImage: string, + facts: Facts, recipe: LocalEvidenceHelperRecipe): boolean => + record.base_image === baseImage && record.base_config_digest === facts.baseConfig && record.daemon_digest === facts.daemonDigest + && record.endpoint_digest === facts.endpointDigest && same(record.platform, facts.platform) + && record.recipe_digest === recipe.recipeDigest; +const build = async (input: PrepareEvidenceHelperInput, run: ReturnType["run"], + facts: Facts, recipe: LocalEvidenceHelperRecipe): Promise<`sha256:${string}`> => { + await hook(input.testHooks?.beforeBuild); + const output = await run(["build", "--quiet", "--pull=false", "--network=none", "--platform", + `${facts.platform.os}/${facts.platform.architecture}`, "--build-arg", `SPAWNFILE_HELPER_BASE=${facts.baseConfig}`, + "-"], recipe.context); + const produced = BUILD_ID.exec(output)?.[1]; + if (!produced) return fail(); + await hook(input.testHooks?.afterBuild); + // Docker's quiet build result is the image config ID produced by this exact + // invocation. Re-attest that immutable ID, never a mutable tag. + const inspected = await inspectHelper(run, produced, facts); + return inspected === produced ? inspected : fail(); +}; +const receiptOwner = async (input: PrepareEvidenceHelperInput, baseImage: string, facts: Facts, + recipe: LocalEvidenceHelperRecipe, key: string, run: ReturnType["run"]): Promise => { + const store = await initializePreparedEvidenceHelperAuthorityStore(input.privateRoot); + await hook(input.testHooks?.beforeReserve); + const pending = await store.reserve(key, newPreparedEvidenceHelperPendingRecord({ + base_config_digest: facts.baseConfig, base_image: baseImage, context: input.context, + daemon_digest: facts.daemonDigest, endpoint_digest: facts.endpointDigest, platform: facts.platform, + recipe_digest: recipe.recipeDigest })); + await hook(input.testHooks?.afterReserve); + if (!matching(pending, baseImage, facts, recipe)) return fail(); + const authority = await store.load(key); if (!authority) return fail(); + if (authority.completion) { + let observed = await inspectMaybe(run, authority.completion.accepted_image_config_digest, facts); + if (observed === null) observed = await build(input, run, facts, recipe); + if (observed !== authority.completion.accepted_image_config_digest) return fail(); + await hook(input.testHooks?.beforeReceipt); return authority.completion.receipt; + } + const produced = await build(input, run, facts, recipe); + await hook(input.testHooks?.beforeComplete); + const completion = await store.complete(key, newPreparedEvidenceHelperCompletionRecord(pending, produced)); + await hook(input.testHooks?.afterComplete); + if (completion.accepted_image_config_digest !== produced) return fail(); + await hook(input.testHooks?.beforeReceipt); return completion.receipt; +}; + +/** Re-attests a completion-bound local config identity or rebuilds an absent exact config. */ +export const prepareEvidenceExportHelper = async ( + input: PrepareEvidenceHelperInput, +): Promise => { + const targetTimeout = timeout(input.timeoutMs); const baseImage = parseDockerBaseImageReference(input.baseImage) ?? fail(); + const run = execution(input, targetTimeout).run; const recipe = await loadLocalEvidenceHelperRecipe(); + const facts = await localFacts(run, input.context, baseImage); + const key = createPreparedEvidenceHelperKey({ baseConfigDigest: facts.baseConfig, context: input.context, + daemonDigest: facts.daemonDigest, endpointDigest: facts.endpointDigest, platform: facts.platform, + recipeDigest: recipe.recipeDigest }); + const mapKey = `${input.privateRoot}\0${key}`; const joined = live.get(mapKey); + if (joined) return joined; + const promise = receiptOwner(input, baseImage, facts, recipe, key, run); + live.set(mapKey, promise); + try { return await promise; } finally { if (live.get(mapKey) === promise) live.delete(mapKey); } +}; + +/** Private target lowering resolves the config digest only after receipt-correlated reattestation. */ +export const resolvePreparedEvidenceHelperImage = async (input: PrepareEvidenceHelperInput, + receipt: PreparedEvidenceHelperReceipt): Promise<{ readonly configDigest: `sha256:${string}`; readonly imageReference: string }> => { + const actual = await prepareEvidenceExportHelper(input); + if (actual.handle !== receipt.handle || actual.digest !== receipt.digest) return fail(); + const baseImage = parseDockerBaseImageReference(input.baseImage) ?? fail(); + const run = execution(input, timeout(input.timeoutMs)).run; + const facts = await localFacts(run, input.context, baseImage); + const recipe = await loadLocalEvidenceHelperRecipe(); const store = await initializePreparedEvidenceHelperAuthorityStore(input.privateRoot); + const key = createPreparedEvidenceHelperKey({ baseConfigDigest: facts.baseConfig, context: input.context, + daemonDigest: facts.daemonDigest, endpointDigest: facts.endpointDigest, platform: facts.platform, + recipeDigest: recipe.recipeDigest }); + const authority: PreparedEvidenceHelperAuthority | null = await store.load(key); + if (!authority?.completion || authority.completion.receipt.handle !== receipt.handle + || authority.completion.receipt.digest !== receipt.digest) return fail(); + return Object.freeze({ configDigest: authority.completion.accepted_image_config_digest, + imageReference: authority.completion.accepted_image_config_digest }); +}; diff --git a/src/evidenceExportHelper/preparedBuilderTypes.ts b/src/evidenceExportHelper/preparedBuilderTypes.ts new file mode 100644 index 00000000..c8117d4f --- /dev/null +++ b/src/evidenceExportHelper/preparedBuilderTypes.ts @@ -0,0 +1,20 @@ +import type { DockerArtifactExecutor } from "../target/dockerArtifactsProvider.js"; + +export interface PrepareEvidenceHelperInput { + readonly baseImage: string; + readonly context: string; + readonly executor: DockerArtifactExecutor; + readonly privateRoot: string; + readonly signal?: AbortSignal; + /** Test-only fault injection at durable/mutation/receipt boundaries. */ + readonly testHooks?: { + readonly afterBuild?: () => Promise | void; + readonly afterComplete?: () => Promise | void; + readonly afterReserve?: () => Promise | void; + readonly beforeBuild?: () => Promise | void; + readonly beforeComplete?: () => Promise | void; + readonly beforeReceipt?: () => Promise | void; + readonly beforeReserve?: () => Promise | void; + }; + readonly timeoutMs?: number; +} diff --git a/src/evidenceExportHelper/recipe.test.ts b/src/evidenceExportHelper/recipe.test.ts new file mode 100644 index 00000000..52362a95 --- /dev/null +++ b/src/evidenceExportHelper/recipe.test.ts @@ -0,0 +1,83 @@ +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { canonicalEvidenceArchive } from "../target/evidenceExportArchive.js"; +import { EVIDENCE_EXPORT_HELPER_PATH } from "../target/evidenceExportProvider.js"; +import { loadLocalEvidenceHelperRecipe } from "./recipe.js"; + +const execute = promisify(execFile); +const roots: string[] = []; +const tarModes = (archive: Uint8Array): ReadonlyMap => { + const bytes = Buffer.from(archive); + const modes = new Map(); + let offset = 0; + while (offset + 512 <= bytes.byteLength && bytes[offset] !== 0) { + const header = bytes.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString("utf8").split("\0", 1)[0]!; + const mode = Number.parseInt(header.subarray(100, 108).toString("ascii").replace(/\0.*$/u, ""), 8); + const size = Number.parseInt(header.subarray(124, 136).toString("ascii").replace(/\0.*$/u, ""), 8); + modes.set(name, mode); + offset += 512 + Math.ceil(size / 512) * 512; + } + return modes; +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))); +}); + +describe("local evidence helper recipe", () => { + it("ships one canonical bounded build context with the exact image contract", async () => { + const recipe = await loadLocalEvidenceHelperRecipe(); + const modes = tarModes(recipe.context); + + expect([...modes.keys()]).toEqual([ + "Dockerfile", "helperProgram.mjs", + ]); + expect(modes).toEqual(new Map([ + ["Dockerfile", 0o444], ["helperProgram.mjs", 0o555], + ])); + expect(modes.get("helperProgram.mjs")! & 0o111).toBe(0o111); + expect(Buffer.from(recipe.context).subarray(257, 265).toString()).toBe("ustar\0" + "00"); + const source = await readFile(new URL("./helperProgram.mjs", import.meta.url), "utf8"); + const dockerfile = Buffer.from(recipe.context).toString("utf8"); + expect(source.startsWith("#!/usr/local/bin/node")).toBe(true); + expect(dockerfile).toContain("LABEL spawnfile.target.evidence-export.helper-contract=\"v1\""); + expect(dockerfile).toContain(`ENV PATH=${EVIDENCE_EXPORT_HELPER_PATH}`); + expect(dockerfile).toContain("USER 65534:65534"); + expect(dockerfile).toContain("ENTRYPOINT [\"/bin/spawnfile-export-helper\"]"); + expect(dockerfile).toContain("COPY helperProgram.mjs /bin/spawnfile-export-helper"); + expect(dockerfile).not.toContain("--chmod"); + expect(dockerfile).not.toContain("# syntax="); + await expect(loadLocalEvidenceHelperRecipe()).resolves.toEqual(recipe); + }); + + it("runs the shipped program into an archive accepted by the strict parser", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-helper-program-"))); + roots.push(root); + const evidence = path.join(root, "evidence"); + await mkdir(path.join(evidence, "nested"), { recursive: true }); + await writeFile(path.join(evidence, "a.txt"), "alpha"); + await writeFile(path.join(evidence, "nested", "b.json"), "{}\n"); + const original = await readFile(new URL("./helperProgram.mjs", import.meta.url), "utf8"); + const program = path.join(root, "helper.mjs"); + await writeFile(program, original.replace( + 'const ROOT = "/spawnfile/evidence";', + `const ROOT = ${JSON.stringify(evidence)};`, + )); + const { stdout } = await execute(process.execPath, [program], { + encoding: "buffer", maxBuffer: 70_000_000, + }); + const parsed = canonicalEvidenceArchive(Uint8Array.from(stdout)); + + expect(parsed.itemCount).toBe(3); + expect(parsed.files.map(({ path: filePath }) => filePath)).toEqual([ + "a.txt", "nested/b.json", + ]); + }); +}); diff --git a/src/evidenceExportHelper/recipe.ts b/src/evidenceExportHelper/recipe.ts new file mode 100644 index 00000000..e07ff53a --- /dev/null +++ b/src/evidenceExportHelper/recipe.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { EVIDENCE_EXPORT_HELPER_PATH } from "../target/evidenceExportProvider.js"; + +export const LOCAL_EVIDENCE_HELPER_RECIPE_VERSION = + "spawnfile.local-evidence-export-helper.recipe.v1" as const; + +const BLOCK = 512; +const MAX_SOURCE_BYTES = 65_536; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; + +const fail = (): never => { throw new Error("Local evidence-export helper recipe failed"); }; +const octal = (value: number, width: number): Buffer => { + const raw = value.toString(8); + if (!Number.isSafeInteger(value) || value < 0 || raw.length > width - 1) return fail(); + return Buffer.from(`${raw.padStart(width - 1, "0")}\0`, "ascii"); +}; +const checksum = (header: Buffer): void => { + header.fill(0x20, 148, 156); + let sum = 0; + for (const byte of header) sum += byte; + if (sum > 0o777777) return fail(); + header.set(Buffer.from(`${sum.toString(8).padStart(6, "0")}\0 `, "ascii"), 148); +}; +const archive = ( + entries: readonly { + readonly bytes: Uint8Array; + readonly mode: 0o444 | 0o555; + readonly path: string; + }[], +): Uint8Array => { + const output: Buffer[] = []; + for (const entry of [...entries].sort((left, right) => + Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) { + const name = Buffer.from(entry.path, "utf8"); + if (name.byteLength < 1 || name.byteLength > 100 + || entry.mode !== 0o444 && entry.mode !== 0o555) return fail(); + const header = Buffer.alloc(BLOCK); + header.set(name, 0); + header.set(octal(entry.mode, 8), 100); + header.set(octal(0, 8), 108); + header.set(octal(0, 8), 116); + header.set(octal(entry.bytes.byteLength, 12), 124); + header.set(octal(0, 12), 136); + header[156] = 48; + header.set(Buffer.from("ustar\0", "ascii"), 257); + header.set(Buffer.from("00", "ascii"), 263); + header.set(octal(0, 8), 329); + header.set(octal(0, 8), 337); + checksum(header); + output.push(header, Buffer.from(entry.bytes)); + const padding = (BLOCK - entry.bytes.byteLength % BLOCK) % BLOCK; + if (padding > 0) output.push(Buffer.alloc(padding)); + } + output.push(Buffer.alloc(BLOCK), Buffer.alloc(BLOCK)); + return Buffer.concat(output); +}; + +const dockerfile = Buffer.from([ + "ARG SPAWNFILE_HELPER_BASE", + "FROM ${SPAWNFILE_HELPER_BASE} AS runtime", + "FROM scratch", + "COPY --from=runtime / /", + "COPY helperProgram.mjs /bin/spawnfile-export-helper", + "LABEL spawnfile.target.evidence-export.helper-contract=\"v1\"", + `ENV PATH=${EVIDENCE_EXPORT_HELPER_PATH}`, + "USER 65534:65534", + "ENTRYPOINT [\"/bin/spawnfile-export-helper\"]", + "CMD []", + "", +].join("\n"), "utf8"); + +export interface LocalEvidenceHelperRecipe { + readonly artifactManifestDigest: `sha256:${string}`; + readonly context: Uint8Array; + readonly recipeDigest: `sha256:${string}`; +} + +const digest = (domain: string, bytes: Uint8Array | string): `sha256:${string}` => + `sha256:${createHash("sha256") + .update(`spawnfile.local-evidence-export-helper.${domain}.v1\0`, "utf8") + .update(bytes) + .digest("hex")}`; + +export const loadLocalEvidenceHelperRecipe = async (): Promise => { + const source = await readFile(new URL("./helperProgram.mjs", import.meta.url)); + if (source.byteLength < 1 || source.byteLength > MAX_SOURCE_BYTES + || !source.subarray(0, 22).toString("utf8").startsWith("#!/usr/local/bin/node")) return fail(); + const context = archive([ + { bytes: dockerfile, mode: 0o444, path: "Dockerfile" }, + { bytes: source, mode: 0o555, path: "helperProgram.mjs" }, + ]); + const recipeDigest = digest("recipe", context); + const artifactManifestDigest = digest("artifact-manifest", recipeDigest); + if (!DIGEST.test(recipeDigest) || !DIGEST.test(artifactManifestDigest)) return fail(); + return Object.freeze({ artifactManifestDigest, context, recipeDigest }); +}; diff --git a/src/ownership/AGENTS.md b/src/ownership/AGENTS.md index 9e1a3dff..0fd96f27 100644 --- a/src/ownership/AGENTS.md +++ b/src/ownership/AGENTS.md @@ -2,12 +2,6 @@ `src/ownership` contains repository-boundary audits and guard tests. Keep checks deterministic, local to this boundary, and place each implementation beside its tests. -`simfileRunOperatorInputs.ts` owns the strict nonsecret operator request, -resolved run-root projection, and correlated public receipt for the composed -Simfile-run boundary. Private target configuration remains stdin-only and is -absent from all three values; this runtime-specific contract must not enter the -generic target deployment modules. - Sibling package guards distinguish physical registry installs from explicit source checkouts. Registry packages may contain non-TypeScript source assets; only actual TypeScript sources activate the source-to-build freshness check. diff --git a/src/ownership/simfileRunOperatorContract.test.ts b/src/ownership/simfileRunOperatorContract.test.ts deleted file mode 100644 index bde5b3d9..00000000 --- a/src/ownership/simfileRunOperatorContract.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { parseSelectedTargetReceipt } from "../target/contracts.js"; -import { - createSimfileRunOperatorReceipt, - parseSimfileRunOperatorInput, - resolveSimfileRunOperatorInput, - verifySimfileRunOperatorReceipt -} from "./simfileRunOperatorInputs.js"; - -const targetSpecPath = path.resolve(import.meta.dirname, "../../specs/TARGETS.md"); - -describe("Simfile run operator target contract", () => { - it("freezes the nonsecret selector, stdin config, named auth, roots, and receipt correlation", async () => { - const spec = await readFile(targetSpecPath, "utf8"); - for (const required of [ - "spawnfile.simfile-run-operator-input.v1", - "spawnfile.simfile-run-operator-receipt.v1", - "target-config-producer gpu-host", - "only literal `--config -` is accepted", - "named local profile `simfile-live`", - "distinct `output/`, `evidence/`, `journal/`, and `cache/`", - "spawnfile.moltnet-release-identity.v1", - '"capabilities": ["pi-bridge"]' - ]) { - expect(spec, required).toContain(required); - } - }); - - it("keeps private configuration and auth values out of the correlated receipt", async () => { - const request = { - auth_profile: "simfile-live", - moltnet_release: { - directory_transport: "operator-path", - required_capability: "pi-bridge", - stamp_version: "spawnfile.moltnet-release-stamp.v1" - }, - run_id: "run-contract", - run_root: "/operator/runs/run-contract", - target_config_transport: "stdin", - target_selector: "gpu-host", - version: "spawnfile.simfile-run-operator-input.v1" - } as const; - expect(() => parseSimfileRunOperatorInput({ - ...request, - target_config: { bearer: "private-value" } - })).toThrow(); - const resolution = resolveSimfileRunOperatorInput({ - request, - moltnet_release: { - architecture: "arm64", - asset: "moltnet_linux_arm64.tar.gz", - asset_sha256: `sha256:${"a".repeat(64)}`, - capabilities: ["pi-bridge"], - release_version: "v0.1.14", - source_revision: "7baeb284ba0b1b5e454476141a557d68b5a4af0d", - version: "spawnfile.moltnet-release-identity.v1" - } - }); - const selectedTarget = parseSelectedTargetReceipt({ - fingerprint: `sha256:${"b".repeat(32)}`, - handle: "opaque_aaaaaaaaaaaaaaaa", - version: "spawnfile.target-resource.selected-target.v1" - }); - const receipt = createSimfileRunOperatorReceipt({ resolution, selected_target: selectedTarget }); - expect(verifySimfileRunOperatorReceipt({ - receipt, - resolution, - selected_target: selectedTarget - })).toEqual(receipt); - const serialized = JSON.stringify(receipt); - expect(serialized).not.toContain(request.run_root); - expect(serialized).not.toContain("target_config"); - expect(serialized).not.toContain("operator-path"); - expect(serialized).not.toContain("private-value"); - - const spec = await readFile(targetSpecPath, "utf8"); - const normalized = spec.replaceAll(/\s+/gu, " "); - expect(normalized).toContain( - "Simfile may persist the secret-free receipt but must never persist or echo the producer's private configuration." - ); - expect(spec).toContain("unpinned `latest` is forbidden"); - }); -}); diff --git a/src/ownership/simfileRunOperatorInputs.test.ts b/src/ownership/simfileRunOperatorInputs.test.ts deleted file mode 100644 index 3476a5a4..00000000 --- a/src/ownership/simfileRunOperatorInputs.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - createSimfileRunOperatorReceipt, - parseSimfileRunOperatorInput, - parseSimfileRunOperatorReceipt, - resolveSimfileRunOperatorInput, - verifySimfileRunOperatorReceipt -} from "./simfileRunOperatorInputs.js"; -import { parseSelectedTargetReceipt } from "../target/contracts.js"; - -const request = { - version: "spawnfile.simfile-run-operator-input.v1", - run_id: "run-example", - target_selector: "gpu-host", - target_config_transport: "stdin", - auth_profile: "simfile-live", - run_root: "/operator/simfile/runs/run-example", - moltnet_release: { - directory_transport: "operator-path", - required_capability: "pi-bridge", - stamp_version: "spawnfile.moltnet-release-stamp.v1" - } -} as const; -const moltnet = { - version: "spawnfile.moltnet-release-identity.v1", - release_version: "v0.1.14", - source_revision: "7baeb284ba0b1b5e454476141a557d68b5a4af0d", - architecture: "arm64", - asset: "moltnet_linux_arm64.tar.gz", - asset_sha256: `sha256:${"a".repeat(64)}`, - capabilities: ["pi-bridge"] -} as const; -const selectedTarget = parseSelectedTargetReceipt({ - version: "spawnfile.target-resource.selected-target.v1", - handle: "opaque_aaaaaaaaaaaaaaaa", - fingerprint: `sha256:${"b".repeat(32)}` -}); - -describe("Simfile run operator input resolution", () => { - it("resolves exact distinct roots and a verified Moltnet identity", () => { - expect(parseSimfileRunOperatorInput(request)).toEqual(request); - const resolution = resolveSimfileRunOperatorInput({ - request, - moltnet_release: moltnet - }); - expect(resolution).toMatchObject({ - version: "spawnfile.simfile-run-operator-resolution.v1", - run_id: "run-example", - target_selector: "gpu-host", - auth_profile: "simfile-live", - roots: { - output: "/operator/simfile/runs/run-example/output", - evidence: "/operator/simfile/runs/run-example/evidence", - journal: "/operator/simfile/runs/run-example/journal", - cache: "/operator/simfile/runs/run-example/cache" - }, - moltnet_release: moltnet - }); - expect(new Set(Object.values(resolution.roots)).size).toBe(4); - }); - - it("rejects private config forms, root drift, latest, and mismatched assets", () => { - for (const invalid of [ - { ...request, target_config_transport: "path" }, - { ...request, target_config: { token: "secret" } }, - { ...request, moltnet_release: { - ...request.moltnet_release, - trusted_authority: { release_version: "fixture" } - } }, - { ...request, run_root: "relative/run-example" }, - { ...request, run_root: "/operator/simfile/runs/other-run" } - ]) expect(() => parseSimfileRunOperatorInput(invalid)).toThrow(); - expect(() => resolveSimfileRunOperatorInput({ - request, - moltnet_release: { ...moltnet, release_version: "latest" } - })).toThrow(); - expect(() => resolveSimfileRunOperatorInput({ - request, - moltnet_release: { ...moltnet, asset: "moltnet_linux_amd64.tar.gz" } - })).toThrow(); - }); -}); - -describe("Simfile run operator receipt", () => { - it("emits and verifies one secret-free correlated receipt", () => { - const resolution = resolveSimfileRunOperatorInput({ request, moltnet_release: moltnet }); - const receipt = createSimfileRunOperatorReceipt({ resolution, selected_target: selectedTarget }); - expect(parseSimfileRunOperatorReceipt(receipt)).toEqual(receipt); - expect(verifySimfileRunOperatorReceipt({ - receipt, - resolution, - selected_target: selectedTarget - })).toEqual(receipt); - const serialized = JSON.stringify(receipt); - expect(serialized).not.toContain(request.run_root); - expect(serialized).not.toContain("target_config"); - expect(serialized).not.toContain("operator-path"); - }); - - it("rejects forged, stale, and contradictory receipt correlation", () => { - const resolution = resolveSimfileRunOperatorInput({ request, moltnet_release: moltnet }); - const receipt = createSimfileRunOperatorReceipt({ resolution, selected_target: selectedTarget }); - for (const forged of [ - { ...receipt, run_id: "run-other" }, - { ...receipt, roots_digest: `sha256:${"c".repeat(64)}` }, - { ...receipt, moltnet_release: { ...receipt.moltnet_release, - asset_sha256: `sha256:${"d".repeat(64)}` } } - ]) expect(() => verifySimfileRunOperatorReceipt({ - receipt: forged, - resolution, - selected_target: selectedTarget - })).toThrow(/correlation/u); - }); -}); diff --git a/src/ownership/simfileRunOperatorInputs.ts b/src/ownership/simfileRunOperatorInputs.ts deleted file mode 100644 index 2d8f6796..00000000 --- a/src/ownership/simfileRunOperatorInputs.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { createHash } from "node:crypto"; -import path from "node:path"; - -import { z } from "zod"; - -import { - assertOrdinaryJsonGraph, - selectedTargetReceiptSchema, - type SelectedTargetReceipt -} from "../target/contracts.js"; - -export const SIMFILE_RUN_OPERATOR_INPUT_VERSION = - "spawnfile.simfile-run-operator-input.v1" as const; -export const SIMFILE_RUN_OPERATOR_RESOLUTION_VERSION = - "spawnfile.simfile-run-operator-resolution.v1" as const; -export const SIMFILE_RUN_OPERATOR_RECEIPT_VERSION = - "spawnfile.simfile-run-operator-receipt.v1" as const; - -const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); -const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); -const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); -const revision = z.string().regex(/^[a-f0-9]{40}$/u); -const releaseVersion = z.string().regex(/^v?\d+\.\d+\.\d+(?:-\d+-g[a-f0-9]{7,40})?$/u) - .refine((value) => value !== "latest"); -const absoluteRunRoot = z.string().max(4_096).refine((value) => - path.isAbsolute(value) && path.normalize(value) === value && value !== path.parse(value).root); - -export const simfileRunOperatorInputSchema = z.object({ - auth_profile: identifier, - moltnet_release: z.object({ - directory_transport: z.literal("operator-path"), - required_capability: z.literal("pi-bridge"), - stamp_version: z.literal("spawnfile.moltnet-release-stamp.v1") - }).strict(), - run_id: runId, - run_root: absoluteRunRoot, - target_config_transport: z.literal("stdin"), - target_selector: identifier, - version: z.literal(SIMFILE_RUN_OPERATOR_INPUT_VERSION) -}).strict().superRefine((value, context) => { - if (path.basename(value.run_root) !== value.run_id) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "run_root must be owned by run_id" }); - } -}); - -export const simfileRunMoltnetIdentitySchema = z.object({ - architecture: z.enum(["amd64", "arm64"]), - asset: z.string().regex(/^moltnet_linux_(?:amd64|arm64)\.tar\.gz$/u), - asset_sha256: digest, - capabilities: z.tuple([z.literal("pi-bridge")]), - release_version: releaseVersion, - source_revision: revision, - version: z.literal("spawnfile.moltnet-release-identity.v1") -}).strict().superRefine((value, context) => { - if (value.asset !== `moltnet_linux_${value.architecture}.tar.gz`) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "Moltnet asset architecture mismatch" }); - } - const described = value.release_version.match(/-g([a-f0-9]{7,40})$/u)?.[1]; - if (described && !value.source_revision.startsWith(described)) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "Moltnet source revision mismatch" }); - } -}); - -const runRootsSchema = z.object({ - cache: absoluteRunRoot, - evidence: absoluteRunRoot, - journal: absoluteRunRoot, - output: absoluteRunRoot -}).strict(); - -export const simfileRunOperatorResolutionSchema = z.object({ - auth_profile: identifier, - moltnet_release: simfileRunMoltnetIdentitySchema, - request_digest: digest, - roots: runRootsSchema, - roots_digest: digest, - run_id: runId, - target_selector: identifier, - version: z.literal(SIMFILE_RUN_OPERATOR_RESOLUTION_VERSION) -}).strict(); - -export const simfileRunOperatorReceiptSchema = z.object({ - auth_profile: identifier, - moltnet_release: simfileRunMoltnetIdentitySchema, - request_digest: digest, - resolution_digest: digest, - roots_digest: digest, - run_id: runId, - selected_target: selectedTargetReceiptSchema, - target_selector: identifier, - version: z.literal(SIMFILE_RUN_OPERATOR_RECEIPT_VERSION) -}).strict(); - -export type SimfileRunOperatorInput = z.infer; -export type SimfileRunMoltnetIdentity = z.infer; -export type SimfileRunOperatorResolution = z.infer; -export type SimfileRunOperatorReceipt = z.infer; - -const canonical = (value: unknown): string => { - if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; - if (value !== null && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record).sort().map((key) => - `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; - } - return JSON.stringify(value); -}; -const hash = (domain: string, value: unknown): `sha256:${string}` => - `sha256:${createHash("sha256").update(`${domain}\0${canonical(value)}`).digest("hex")}`; -const parse = (schema: z.ZodType, raw: unknown): Value => { - assertOrdinaryJsonGraph(raw); - return schema.parse(raw); -}; - -export const parseSimfileRunOperatorInput = (raw: unknown): SimfileRunOperatorInput => - parse(simfileRunOperatorInputSchema, raw); -export const parseSimfileRunOperatorResolution = (raw: unknown): SimfileRunOperatorResolution => - parse(simfileRunOperatorResolutionSchema, raw); -export const parseSimfileRunOperatorReceipt = (raw: unknown): SimfileRunOperatorReceipt => - parse(simfileRunOperatorReceiptSchema, raw); - -export const createSimfileRunOperatorRequestDigest = (raw: unknown): `sha256:${string}` => - hash(SIMFILE_RUN_OPERATOR_INPUT_VERSION, parseSimfileRunOperatorInput(raw)); - -export const resolveSimfileRunOperatorInput = (input: { - readonly request: unknown; - readonly moltnet_release: unknown; -}): SimfileRunOperatorResolution => { - const request = parseSimfileRunOperatorInput(input.request); - const moltnetRelease = parse(simfileRunMoltnetIdentitySchema, input.moltnet_release); - const roots = { - cache: path.join(request.run_root, "cache"), - evidence: path.join(request.run_root, "evidence"), - journal: path.join(request.run_root, "journal"), - output: path.join(request.run_root, "output") - }; - return parseSimfileRunOperatorResolution({ - auth_profile: request.auth_profile, - moltnet_release: moltnetRelease, - request_digest: createSimfileRunOperatorRequestDigest(request), - roots, - roots_digest: hash("spawnfile.simfile-run-roots.v1", roots), - run_id: request.run_id, - target_selector: request.target_selector, - version: SIMFILE_RUN_OPERATOR_RESOLUTION_VERSION - }); -}; - -export const createSimfileRunOperatorReceipt = (input: { - readonly resolution: unknown; - readonly selected_target: SelectedTargetReceipt; -}): SimfileRunOperatorReceipt => { - const resolution = parseSimfileRunOperatorResolution(input.resolution); - const selectedTarget = parse(selectedTargetReceiptSchema, input.selected_target); - return parseSimfileRunOperatorReceipt({ - auth_profile: resolution.auth_profile, - moltnet_release: resolution.moltnet_release, - request_digest: resolution.request_digest, - resolution_digest: hash(SIMFILE_RUN_OPERATOR_RESOLUTION_VERSION, resolution), - roots_digest: resolution.roots_digest, - run_id: resolution.run_id, - selected_target: selectedTarget, - target_selector: resolution.target_selector, - version: SIMFILE_RUN_OPERATOR_RECEIPT_VERSION - }); -}; - -export const verifySimfileRunOperatorReceipt = (input: { - readonly receipt: unknown; - readonly resolution: unknown; - readonly selected_target: SelectedTargetReceipt; -}): SimfileRunOperatorReceipt => { - const receipt = parseSimfileRunOperatorReceipt(input.receipt); - const expected = createSimfileRunOperatorReceipt({ - resolution: input.resolution, - selected_target: input.selected_target - }); - if (canonical(receipt) !== canonical(expected)) { - throw new TypeError("Simfile run operator receipt correlation is invalid"); - } - return receipt; -}; diff --git a/src/report/types.ts b/src/report/types.ts index 3528b5b9..7bab07ce 100644 --- a/src/report/types.ts +++ b/src/report/types.ts @@ -222,9 +222,16 @@ export interface ContainerMoltnetPlanSummary { architecture: "amd64" | "arm64"; asset: string; asset_sha256: `sha256:${string}`; - capabilities: readonly ["pi-bridge"]; - release_version: string; - source_revision: string; + capabilities: readonly ["pi-bridge"] | readonly ["daimon-bridge", "pi-bridge"]; + development?: { + mode: "local-development"; + non_production: true; + unsigned: true; + unpublished: true; + }; + release_version?: string; + source_revision?: string; + source_sha256?: `sha256:${string}`; version: "spawnfile.moltnet-release-identity.v1"; }; server_plans: ContainerMoltnetServerPlanSummary[]; diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 884b2539..da91f82e 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -15,6 +15,7 @@ src/runtime/ ├── containerPackageOverrides.ts # Runtime install npm package override contract consumed by container.ts ├── registry.ts # Bundled adapter registration and lookup ├── scheduleUtils.ts # Shared duration schedule helpers for runtime lowering +├── daimon/ # Public Daimon organization-host adapter ├── openclaw/ # OpenClaw adapter implementation ├── picoclaw/ # PicoClaw adapter implementation ├── common.test.ts # Shared runtime helper tests @@ -27,9 +28,9 @@ Adapter-specific behavior belongs in the runtime subfolders. That includes runti `common.ts` additionally exports `ensureNoopolisRunId(env = process.env)`: the one place a run id is ever generated. It returns the host-provided value untouched when `resolveNoopolisRunId` already finds one, otherwise it generates a fresh id (`run-`) and stamps it onto `env` before returning it. It is exported through this folder's barrel (`index.ts`) so `src/compiler/runProject.ts` and `src/compiler/upProject.ts` can call it once, at the top of their `run`/`up` execution functions, before invoking `compileProject`/`buildProject` — never from inside `compileProject.ts`/`buildProject.ts` themselves, which must stay deterministic functions of whatever is already in the host env. `src/e2e/officeSim.ts` calls it too, since that harness builds a container directly rather than going through `runProject`/`upProject`. Without this, a host that never sets `NOOPOLIS_RUN_ID` (a bare `spawnfile up`, or an E2E harness) leaves `createRuntimeContainerEnv` with nothing to stamp, and moltnet's `causal.jsonl` capture ends up empty even though mneme/daimon still emit under their own `"unset-run"` fallback. -`containerPackageOverrides.ts` defines the `RuntimeContainerPackageOverrides` contract (`packageName -> { filename }`) that `createRuntimeInstallRecipe` (`container.ts`) accepts as an optional `packageOverrides` argument. When a runtime install npm package (currently only `@noopolis/daimon` / `@noopolis/mneme`, in the `daimon` and `pi` recipe cases) has an override entry, its install spec is rewritten from the pinned `name@version` registry form to the vendored tarball path under `/opt/spawnfile/vendor`, and a single `COPY container/vendor/ /opt/spawnfile/vendor/` line is added to that recipe's `copyCommands`. With no overrides (the default for every standard compile), every install spec and the Dockerfile it renders into stay byte-identical to before this module existed. The actual `npm pack`-into-build-context step is compiler-level I/O and lives in `src/compiler/containerPackageOverrides.ts`; this folder only owns the recipe-shaping contract, never the packing. +`containerPackageOverrides.ts` defines the `RuntimeContainerPackageOverrides` contract (`packageName -> { filename }`) used by the legacy Pi recipe. The Phase-A Daimon host never installs a local package: it copies a separately released generic image by immutable digest and verifies that image's capability receipt. The actual `npm pack`-into-build-context step is compiler-level I/O and lives in `src/compiler/containerPackageOverrides.ts`; this folder only owns recipe shaping, never packing or engine installation. -`types.ts`'s `ContainerTarget` carries an optional `engineByNodeId?: Record` (Piece 5, Slice B): a generic passthrough slot for an adapter that has an "engine kind" concept per compiled node to disclose it on the compile report. Only `src/runtime/pi/adapter.ts`'s `createContainerTargets` currently populates it (node id -> resolved `PI_ENGINE_KINDS` value, e.g. `"scripted"`); `src/compiler/containerArtifactsPlans.ts`/`containerArtifacts.ts` thread it, unchanged, into `ContainerRuntimeInstanceReport.engine_by_node_id` (`src/report/types.ts`). Other adapters simply omit it. +`types.ts`'s `ContainerTarget` carries an optional `engineByNodeId?: Record` passthrough slot for adapters that disclose a native engine kind per compiled node. The Pi adapter reports generated Pi engine kinds; the Daimon adapter reports its public `codex`/`grok`/`agy` engine intents. `src/compiler/containerArtifactsPlans.ts`/`containerArtifacts.ts` thread the map unchanged into `ContainerRuntimeInstanceReport.engine_by_node_id` (`src/report/types.ts`). Other adapters omit it. `MNEME_RECALL_MODE` (mneme's own `MNEME_RECALL_MODE_ENV`, see `ecosystem/mneme/src/runtime/recallMode.ts`) is the B70 memory recall-mode ablation knob (`on`/`off`/`shuffled`). It follows the same trust shape as `NOOPOLIS_RUN_ID` and `MNEME_OLLAMA_BASE_URL`: a harness/container-injected environment variable, read directly inside mneme's `JsonlMemoryRuntime` constructor, never a config field this package lowers, never a model-facing tool argument, and never model-writable. That is intentional — Daimon and the Pi prelude (`appPreludeSource.ts`'s `createMemoryRuntimeOptions`) pass no `recallMode` field, so a generated container that never sets the env var always resolves to `on`, and this folder needed zero changes to add the ablation knob. Only an operator's own process env, or `src/runtime/pi/appCliSource.test.ts`'s "wired to the real @noopolis/mneme memory runtime" case (which sets it directly, in-process, per mode) may set it. diff --git a/src/runtime/container.fallback.test.ts b/src/runtime/container.fallback.test.ts index 67d94f65..586cf4e2 100644 --- a/src/runtime/container.fallback.test.ts +++ b/src/runtime/container.fallback.test.ts @@ -147,7 +147,7 @@ describe("runtime container install recipe fallbacks", () => { ); }); - it("creates a Daimon npm install recipe when the runtime opts into npm", async () => { + it("rejects a Daimon npm install because public hosts require a generic image", async () => { const { createRuntimeInstallRecipe, RUNTIME_INSTALL_ROOT } = await loadContainerModule({ daimon: { ecosystem: "node", @@ -160,14 +160,9 @@ describe("runtime container install recipe fallbacks", () => { version: "0.1.2" } }); - const recipe = await createRuntimeInstallRecipe("daimon"); - - expect(recipe.runtimeRoot).toBe(`${RUNTIME_INSTALL_ROOT}/daimon`); - expect(recipe.copyCommands).toEqual([]); - expect(recipe.commands).toEqual([ - `mkdir -p ${RUNTIME_INSTALL_ROOT}/daimon`, - `cd ${RUNTIME_INSTALL_ROOT}/daimon && npm install --omit=dev --no-fund --no-audit @noopolis/daimon@0.1.2 @noopolis/mneme@0.1.1 @earendil-works/pi-coding-agent@0.79.10 @earendil-works/pi-ai@0.79.10` - ]); + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow( + "requires a pinned generic Daimon runtime image" + ); }); it("rejects OpenClaw when no compiled artifact is available", async () => { @@ -194,7 +189,7 @@ describe("runtime container install recipe fallbacks", () => { ); }); - it("rejects non-npm Daimon artifact installs", async () => { + it("rejects non-image Daimon artifact installs", async () => { const { createRuntimeInstallRecipe } = await loadContainerModule({ daimon: { binaryName: "daimon", @@ -214,7 +209,7 @@ describe("runtime container install recipe fallbacks", () => { }); await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow( - /has no compiled artifact recipe for github_release_archive/ + "requires a pinned generic Daimon runtime image" ); }); diff --git a/src/runtime/container.test.ts b/src/runtime/container.test.ts index 2eca6f9d..625fb0e1 100644 --- a/src/runtime/container.test.ts +++ b/src/runtime/container.test.ts @@ -7,6 +7,7 @@ describe("runtime container install recipes", () => { afterEach(() => { delete process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE; delete process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE; + delete process.env.SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT; delete process.env.SPAWNFILE_OPENCLAW_RUNTIME_IMAGE; delete process.env.SPAWNFILE_PI_RUNTIME_BASE_IMAGE; delete process.env.SPAWNFILE_PICOCLAW_RUNTIME_IMAGE; @@ -53,9 +54,16 @@ describe("runtime container install recipes", () => { expect(recipe.runtimeName).toBe("daimon"); expect(recipe.runtimeRoot).toBe(`${RUNTIME_INSTALL_ROOT}/daimon`); - expect(recipe.commands).toEqual([]); + expect(recipe.commands).toEqual(expect.arrayContaining([ + expect.stringContaining("capability-receipt.json"), + expect.stringContaining('actual="$(sha256sum'), + `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/daimon-runtime /usr/local/bin/daimon-runtime`, + `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/codex /usr/local/bin/codex`, + `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/grok /usr/local/bin/grok`, + `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/agy /usr/local/bin/agy` + ])); expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon:0.1.2 ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` + `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` ]); }); @@ -110,18 +118,29 @@ describe("runtime container install recipes", () => { expect(recipe.commands).toEqual([`mkdir -p ${RUNTIME_INSTALL_ROOT}/pi`]); }); - it("uses a prebuilt Daimon runtime artifact image when configured", async () => { - process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-daimon:test"; + it("allows only an exact Daimon runtime image and receipt override", async () => { + process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa"; + process.env.SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT = "sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7"; const recipe = await createRuntimeInstallRecipe("daimon"); expect(recipe.baseImage).toBeUndefined(); - expect(recipe.commands).toEqual([]); + expect(recipe.commands).toEqual(expect.arrayContaining([ + expect.stringContaining("capability-receipt.json"), + `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/daimon-runtime /usr/local/bin/daimon-runtime` + ])); expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon:test ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` + `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` ]); }); + it("rejects mutable Daimon runtime image overrides", async () => { + process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-daimon:test"; + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow( + "source and tag-only overrides are disabled" + ); + }); + it("uses a prebuilt OpenClaw runtime artifact image when configured", async () => { process.env.SPAWNFILE_OPENCLAW_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-openclaw:test"; @@ -148,16 +167,11 @@ describe("runtime container install recipes", () => { ]); }); - it("treats the legacy Daimon base-image env as a copyable artifact", async () => { + it("ignores the legacy Daimon base-image override", async () => { process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE = "noopolis/spawnfile-runtime-daimon:legacy"; - - const recipe = await createRuntimeInstallRecipe("daimon"); - - expect(recipe.baseImage).toBeUndefined(); - expect(recipe.commands).toEqual([]); - expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon:legacy ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` - ]); + await expect(createRuntimeInstallRecipe("daimon")).resolves.toMatchObject({ + copyCommands: [expect.stringContaining("@sha256:")] + }); }); it("omits NOOPOLIS_RUN_ID from every recipe's env when unset", async () => { diff --git a/src/runtime/container.ts b/src/runtime/container.ts index f544a35d..34d742e3 100644 --- a/src/runtime/container.ts +++ b/src/runtime/container.ts @@ -12,11 +12,12 @@ import { resolveRuntimeInstallSelection } from "./install.js"; export const RUNTIME_INSTALL_ROOT = "/opt/spawnfile/runtime-installs"; const PI_RUNTIME_BASE_IMAGE_ENV = "SPAWNFILE_PI_RUNTIME_BASE_IMAGE"; const DAIMON_RUNTIME_IMAGE_ENV = "SPAWNFILE_DAIMON_RUNTIME_IMAGE"; -const DAIMON_RUNTIME_BASE_IMAGE_ENV = "SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE"; +const DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV = "SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT"; +const DAIMON_CAPABILITY_RECEIPT_FILE = "capability-receipt.json"; const OPENCLAW_RUNTIME_IMAGE_ENV = "SPAWNFILE_OPENCLAW_RUNTIME_IMAGE"; const PICOCLAW_RUNTIME_IMAGE_ENV = "SPAWNFILE_PICOCLAW_RUNTIME_IMAGE"; const DAIMON_PACKAGE_NAME = "@noopolis/daimon"; -const DAIMON_PACKAGE_VERSION = "0.1.2"; +const PI_DAIMON_PACKAGE_VERSION = "0.1.2"; const MNEME_PACKAGE_NAME = "@noopolis/mneme"; const MNEME_PACKAGE_VERSION = "0.1.1"; const PI_AI_PACKAGE_NAME = "@earendil-works/pi-ai"; @@ -97,6 +98,43 @@ const resolveRuntimeImageRef = ( ? `${selection.image}:${selection.tag}` : undefined); +/** + * Daimon is distributed only as a generic, source-free runtime image. A + * development override is deliberately narrow: it can repeat the exact + * immutable image and receipt selected by the registry, never substitute a + * checkout, mutable tag, or a host-installed CLI. + */ +const resolveDaimonRuntimeImageRef = ( + selection: Awaited> +): { capabilityReceipt: string; image: string } => { + if ( + selection.kind !== "container_image" || + !selection.digest || + !selection.capabilityReceipt + ) { + throw new SpawnfileError( + "runtime_error", + "Daimon organization runtime v1 requires a pinned generic Daimon runtime image" + ); + } + + const pinnedImage = `${selection.image}@${selection.digest}`; + const override = process.env[DAIMON_RUNTIME_IMAGE_ENV]?.trim(); + if (!override) { + return { capabilityReceipt: selection.capabilityReceipt, image: pinnedImage }; + } + + const receipt = process.env[DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV]?.trim(); + if (override !== pinnedImage || receipt !== selection.capabilityReceipt) { + throw new SpawnfileError( + "runtime_error", + "Daimon runtime image overrides must exactly match the pinned image digest and capability receipt; source and tag-only overrides are disabled" + ); + } + + return { capabilityReceipt: selection.capabilityReceipt, image: override }; +}; + export interface RuntimeInstallRecipeOptions { /** * Compile-time-only local overrides for this runtime's install npm @@ -180,48 +218,16 @@ export const createRuntimeInstallRecipe = async ( }; } case "daimon": { - const daimonRuntimeImage = - process.env[DAIMON_RUNTIME_IMAGE_ENV]?.trim() || - process.env[DAIMON_RUNTIME_BASE_IMAGE_ENV]?.trim() || - (selection.kind === "container_image" - ? `${selection.image}:${selection.tag}` - : undefined); - - if (daimonRuntimeImage) { - return { - commands: [], - copyCommands: [createRuntimeImageCopyCommand(daimonRuntimeImage, installRoot)], - env: containerEnv, - runtimeName, - runtimeRoot: installRoot - }; - } - - if (selection.kind !== "npm") { - throw new SpawnfileError( - "runtime_error", - `Runtime ${runtimeName} has no compiled artifact recipe for ${selection.kind}` - ); - } - - const npmPackages = [ - resolveInstallPackageSpec(selection.packageName, selection.version, packageOverrides), - resolveInstallPackageSpec(MNEME_PACKAGE_NAME, MNEME_PACKAGE_VERSION, packageOverrides), - `${PI_CODING_AGENT_PACKAGE_NAME}@${PI_PACKAGE_VERSION}`, - `${PI_AI_PACKAGE_NAME}@${PI_PACKAGE_VERSION}` - ]; - + const daimonRuntime = resolveDaimonRuntimeImageRef(selection); return { commands: [ - `mkdir -p ${installRoot}`, - `cd ${installRoot} && npm install --omit=dev --no-fund --no-audit ${npmPackages.join(" ")}` + `test -f ${installRoot}/${DAIMON_CAPABILITY_RECEIPT_FILE} && actual="$(sha256sum ${installRoot}/${DAIMON_CAPABILITY_RECEIPT_FILE} | awk '{print "sha256:" $1}')" && test "$actual" = ${JSON.stringify(daimonRuntime.capabilityReceipt)}`, + `ln -sf ${installRoot}/bin/daimon-runtime /usr/local/bin/daimon-runtime`, + `ln -sf ${installRoot}/bin/codex /usr/local/bin/codex`, + `ln -sf ${installRoot}/bin/grok /usr/local/bin/grok`, + `ln -sf ${installRoot}/bin/agy /usr/local/bin/agy` ], - copyCommands: needsVendorCopyCommand( - [selection.packageName, MNEME_PACKAGE_NAME], - packageOverrides - ) - ? [createVendorCopyCommand()] - : [], + copyCommands: [createRuntimeImageCopyCommand(daimonRuntime.image, installRoot)], env: containerEnv, runtimeName, runtimeRoot: installRoot @@ -237,7 +243,7 @@ export const createRuntimeInstallRecipe = async ( const prebuiltBaseImage = process.env[PI_RUNTIME_BASE_IMAGE_ENV]?.trim() || undefined; const npmPackages = [ - resolveInstallPackageSpec(DAIMON_PACKAGE_NAME, DAIMON_PACKAGE_VERSION, packageOverrides), + resolveInstallPackageSpec(DAIMON_PACKAGE_NAME, PI_DAIMON_PACKAGE_VERSION, packageOverrides), resolveInstallPackageSpec(MNEME_PACKAGE_NAME, MNEME_PACKAGE_VERSION, packageOverrides), `${selection.packageName}@${selection.version}`, `${PI_AI_PACKAGE_NAME}@${selection.version}` diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md new file mode 100644 index 00000000..230486ce --- /dev/null +++ b/src/runtime/daimon/AGENTS.md @@ -0,0 +1,17 @@ +# Daimon Runtime Adapter + +This folder lowers a resolved Spawnfile organization into the public +`noopolis.daimon.organization-runtime.v1` contract. It owns no model CLI +argv, credential copying, MCP process, scheduler, or Moltnet +bridge code. Daimon owns one wake at a time after this adapter has prepared +the organization artifact. + +Keep the generated configuration strict and source-free. The adapter emits +one organization host target (at most 32 agents), not one generated engine +application per agent. It permits only compiler-owned Moltnet public-wake +attachments; Daimon consumes a generic 0700 private ingress itself. `runtime: +pi` remains the legacy generated Pi path. + +The consumed Daimon manifest may declare the AGY host realm's stable volume +target and opaque unlock slot. This adapter renders those resources but never +starts D-Bus, runs AGY, or reads either secret. diff --git a/src/runtime/daimon/CLAUDE.md b/src/runtime/daimon/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/runtime/daimon/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts new file mode 100644 index 00000000..ad6ad418 --- /dev/null +++ b/src/runtime/daimon/adapter.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; + +import { createRootfsFiles } from "../../compiler/containerArtifactsRender.js"; +import { renderEntrypoint } from "../../compiler/containerEntrypointRender.js"; +import { resolveInstancePaths } from "../../compiler/containerTargetPlanResolution.js"; +import type { RuntimeTargetPlan } from "../../compiler/containerArtifactsTypes.js"; +import { createMoltnetNodeConfigContent } from "../../compiler/moltnetNodeConfig.js"; +import { resolveRuntimeConfig } from "../../compiler/moltnetRuntimeConfig.js"; +import type { CompilePlan } from "../../compiler/types.js"; +import { createRuntimeInstallRecipe } from "../container.js"; +import { createPiTestNode } from "../pi/testHelpers.js"; + +import { daimonAdapter } from "./adapter.js"; +import { DAIMON_CONFIG_FILE } from "./config.js"; + +const createDaimonNode = (id: string, name = id, engine = "codex") => { + const node = createPiTestNode({ + name, + runtime: { name: "daimon", options: { engine } } + }); + if (engine === "codex") return node; + const { model: _model, ...execution } = node.execution!; + return { ...node, execution }; +}; + +const createPlan = async (): Promise => { + const first = createDaimonNode("first", "First"); + const second = createDaimonNode("second", "Second"); + const firstCompiled = await daimonAdapter.compileAgent(first); + const secondCompiled = await daimonAdapter.compileAgent(second); + const target = (await daimonAdapter.createContainerTargets!([ + { emittedFiles: firstCompiled.files, id: "agent:first", kind: "agent", slug: "first", value: first }, + { emittedFiles: secondCompiled.files, id: "agent:second", kind: "agent", slug: "second", value: second } + ]))[0]!; + const instancePaths = resolveInstancePaths("daimon", target.id, daimonAdapter.container); + return { + engineByNodeId: target.engineByNodeId, + envFiles: [], + id: target.id, + instancePaths, + meta: daimonAdapter.container, + modelAuthMethods: {}, + modelSecretsRequired: [], + port: daimonAdapter.container.port, + recipeEnv: {}, + runtimeName: "daimon", + runtimeRoot: "/opt/spawnfile/runtime-installs/daimon", + sourceIds: target.sourceIds, + targetFiles: target.files + }; +}; + +describe("daimonAdapter", () => { + it("emits one strict organization host and no generated engine application", async () => { + const plan = await createPlan(); + const config = plan.targetFiles.find((file) => file.path === DAIMON_CONFIG_FILE); + + expect(plan.id).toBe("daimon-organization"); + expect(plan.targetFiles.some((file) => file.path === "runtime/app.mjs")).toBe(false); + expect(plan.targetFiles.some((file) => file.path === "runtime/schedule.mjs")).toBe(false); + expect(JSON.parse(config!.content)).toMatchObject({ + version: "noopolis.daimon.organization-runtime.v1", + host: { bindHost: "127.0.0.1", controlTokenEnv: "SPAWNFILE_DAIMON_CONTROL_TOKEN", port: 19700 }, + agents: [ + { id: "agent:first", engine: { kind: "codex" } }, + { id: "agent:second", engine: { kind: "codex" } } + ] + }); + }); + + it("creates physical per-agent roots and invokes only the public daemon command", async () => { + const plan = await createPlan(); + const rootfs = createRootfsFiles([plan]); + const config = rootfs.find((file) => file.path.endsWith(`/${DAIMON_CONFIG_FILE}`)); + const start = rootfs.find((file) => file.path.endsWith("/daimon-start.sh")); + const entrypoint = renderEntrypoint([plan], []); + + expect(config!.content).toContain("/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/first"); + expect(start!.content).toContain("exec daimon-runtime run --config"); + expect(start!.content).toContain("/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json"); + expect(start!.content).not.toContain(""); + expect(start!.content).not.toContain("codex exec"); + expect(start!.content).toContain("install -d -m 700"); + expect(start!.content).toContain('if [ "$#" -gt 0 ]; then exec daimon-runtime "$@"; fi'); + expect(start!.content).toContain(".daimon-inbound"); + expect(start!.content).toContain("stat -c %a"); + expect(entrypoint).toContain("'bash' '/opt/spawnfile/runtime-installs/daimon/daimon-start.sh'"); + expect(entrypoint).not.toContain("SPAWNFILE_CLI_AUTH_JSON"); + expect(daimonAdapter.container.systemDeps).toEqual([ + "bash", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux" + ]); + }); + + it("compiles and mounts a three-engine Moltnet trace without invoking an engine", async () => { + const agents = (["codex", "grok", "agy"] as const).map((engine) => ({ + id: `agent:${engine}`, + node: createDaimonNode(engine, engine.toUpperCase(), engine), + slug: engine + })); + const target = (await daimonAdapter.createContainerTargets!(await Promise.all(agents.map(async (agent) => ({ + emittedFiles: (await daimonAdapter.compileAgent(agent.node)).files, + id: agent.id, + kind: "agent" as const, + slug: agent.slug, + value: agent.node + })))))[0]!; + const instancePaths = resolveInstancePaths("daimon", target.id, daimonAdapter.container); + const runtimePlan: RuntimeTargetPlan = { + engineByNodeId: target.engineByNodeId, + envFiles: [], id: target.id, instancePaths, meta: daimonAdapter.container, + modelAuthMethods: {}, modelSecretsRequired: [], port: daimonAdapter.container.port, + recipeEnv: {}, runtimeName: "daimon", runtimeRoot: "/opt/spawnfile/runtime-installs/daimon", + sourceIds: target.sourceIds, targetFiles: target.files + }; + const compilePlan = { nodes: [] } as unknown as CompilePlan; + const attachments = agents.map((agent) => JSON.parse(createMoltnetNodeConfigContent({ + agentNode: agent.node, + attachment: { memberId: agent.id, network: "local", teamSource: null }, + networkServer: { auth: { mode: "none" }, mode: "external", url: "http://127.0.0.1:9999" }, + nodeSlug: agent.slug, + plan: compilePlan, + serverPlan: { baseUrl: "http://127.0.0.1:9999", rooms: [] } + }).content)); + const entrypoint = renderEntrypoint([runtimePlan], [], { + moltnet: { nodePlans: [{ configPath: "/config/moltnet.json", networkId: "local" }] as any, serverPlans: [] } + }); + + expect(JSON.parse(target.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).agents) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ engine: { kind: "codex" } }), + expect.objectContaining({ engine: { kind: "grok" } }), + expect.objectContaining({ engine: { kind: "agy" } }) + ])); + expect(target.opaqueMountTargets).toEqual([ + "/var/lib/spawnfile/daimon/agy-unlock-secret" + ]); + expect(target.persistentMounts).toEqual([ + { + id: "daimon-agy-subscription-realm", + mountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "Daimon host AGY subscription realm" + }, + { + id: "daimon-agy-runtime-home-agy", + mountPath: "/runtime-homes/agy", + reason: "Daimon AGY subscription runtime home for agent:agy" + } + ]); + const start = target.files.find((file) => file.path === "runtime/daimon-start.sh")!; + expect(start.content.indexOf("/runtime-homes/agy")).toBeLessThan( + start.content.indexOf('if [ "$#" -gt 0 ]; then exec daimon-runtime "$@"; fi') + ); + expect(JSON.stringify(target.files)).not.toMatch( + /DBUS_SESSION_BUS_ADDRESS|gnome-keyring-daemon|antigravity-oauth-token/u + ); + for (const [index, agent] of agents.entries()) { + expect(attachments[index].attachments[0].runtime).toEqual(resolveRuntimeConfig( + compilePlan, agent.node, agent.slug, "local", agent.id + )); + expect(attachments[index].attachments[0].runtime).toMatchObject({ + control_url: "http://127.0.0.1:19700", + kind: "daimon", + token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" + }); + } + expect(entrypoint.indexOf("/healthz")).toBeGreaterThan(entrypoint.indexOf("daimon-start.sh")); + expect(entrypoint.indexOf("moltnet node")).toBeGreaterThan(entrypoint.indexOf("/healthz")); + expect(entrypoint).not.toContain("Authorization: Bearer"); + expect(entrypoint).not.toMatch(/(?:codex|grok|agy) (?:exec|run)/u); + }); + + it("does not emit AGY state when every agent uses a portable engine", async () => { + const target = (await daimonAdapter.createContainerTargets!([ + { emittedFiles: [], id: "agent:codex", kind: "agent", slug: "codex", value: createDaimonNode("codex") }, + { emittedFiles: [], id: "agent:grok", kind: "agent", slug: "grok", value: createDaimonNode("grok", "Grok", "grok") } + ]))[0]!; + expect(target.opaqueMountTargets).toBeUndefined(); + expect(target.persistentMounts).toBeUndefined(); + }); + + it("rejects 33 agents before emitting a partial target", async () => { + const inputs = Array.from({ length: 33 }, (_, index) => { + const node = createDaimonNode(`agent-${index}`); + return { + emittedFiles: [], + id: `agent:${index}`, + kind: "agent" as const, + slug: `agent-${index}`, + value: node + }; + }); + + await expect(daimonAdapter.createContainerTargets!(inputs)).rejects.toThrow( + "Daimon organization runtime v1 supports at most 32 agents; found 33. Split the organization across explicit runtime boundaries." + ); + }); + + it("selects a source-free immutable runtime image", async () => { + const recipe = await createRuntimeInstallRecipe("daimon"); + expect(recipe.copyCommands).toEqual([ + expect.stringContaining("noopolis/spawnfile-runtime-daimon@sha256:") + ]); + expect(recipe.commands.join("\n")).toContain("daimon-runtime"); + expect(recipe.commands.join("\n")).not.toContain("npm install"); + }); + + it("fails closed for schedules, MCP, and non-Moltnet Daimon surface behavior", async () => { + await expect(daimonAdapter.compileAgent(createDaimonNode("schedule", "Schedule"))).resolves.toBeDefined(); + await expect(daimonAdapter.compileAgent(createPiTestNode({ + runtime: { name: "daimon", options: {} }, + schedule: { every: "1m", kind: "every", prompt: "work" } + }))).rejects.toThrow("does not lower schedules yet"); + await expect(daimonAdapter.compileAgent(createPiTestNode({ + runtime: { name: "daimon", options: { engine: "grok" } } + }))).rejects.toThrow("must omit Spawnfile execution.model"); + expect(() => daimonAdapter.assertSupportedSurfaces?.({ moltnet: [{ network: "test" }] } as any)).not.toThrow(); + expect(() => daimonAdapter.assertSupportedSurfaces?.({ discord: [{}] } as any)).toThrow("only lowers Moltnet"); + }); + + it("validates the complete public model, option, MCP, and empty-target boundaries", async () => { + expect(() => daimonAdapter.assertSupportedModelTarget?.({ + auth: { method: "codex" }, provider: "openai" + } as any)).not.toThrow(); + expect(() => daimonAdapter.assertSupportedModelTarget?.({ + auth: { method: "codex" }, endpoint: "https://example.invalid", provider: "openai" + } as any)).toThrow(/optional OpenAI Codex/u); + expect(() => daimonAdapter.assertSupportedSurfaces?.({ discord: [], moltnet: [] } as any)).not.toThrow(); + + await expect(daimonAdapter.compileAgent({ + ...createDaimonNode("mcp"), + mcpServers: [{ name: "unsupported" }] + } as any)).rejects.toThrow(/does not lower MCP/u); + await expect(daimonAdapter.createContainerTargets!([])).resolves.toEqual([]); + + expect(daimonAdapter.validateRuntimeOptions?.({ engine: 7 } as any)).toEqual([ + expect.objectContaining({ level: "error" }) + ]); + expect(daimonAdapter.validateRuntimeOptions?.({ engine: "codex", unexpected: true } as any)) + .toEqual([expect.objectContaining({ message: expect.stringContaining("unexpected") })]); + }); + + it("preserves non-workspace files and rejects invalid engines and oversized instructions", async () => { + const node = createDaimonNode("files"); + const target = (await daimonAdapter.createContainerTargets!([{ + emittedFiles: [{ content: "root\n", path: "root.txt" }], + id: "agent:files", kind: "agent", slug: "files", value: node + }]))[0]!; + expect(target.files).toContainEqual({ content: "root\n", path: "root.txt" }); + + expect(daimonAdapter.validateRuntimeOptions?.({ engine: "invalid" })) + .toEqual([expect.objectContaining({ message: expect.stringContaining("engine must be one of") })]); + await expect(daimonAdapter.createContainerTargets!([{ + emittedFiles: [], id: "agent:invalid", kind: "agent", slug: "invalid", + value: createDaimonNode("invalid", "Invalid", "invalid") + }])).rejects.toThrow(/engine must be one of/u); + + const oversized = { + ...createDaimonNode("oversized"), + docs: [{ content: "x".repeat(4_097), path: "AGENTS.md", role: "instructions" }] + } as any; + await expect(daimonAdapter.createContainerTargets!([{ + emittedFiles: [], id: "agent:oversized", kind: "agent", slug: "oversized", value: oversized + }])).rejects.toThrow(/instructions/u); + }); +}); diff --git a/src/runtime/daimon/adapter.ts b/src/runtime/daimon/adapter.ts new file mode 100644 index 00000000..d6771d02 --- /dev/null +++ b/src/runtime/daimon/adapter.ts @@ -0,0 +1,119 @@ +import type { EffectiveModelTarget, ResolvedAgentNode, ResolvedAgentSurfaces } from "../../compiler/types.js"; +import { SpawnfileError } from "../../shared/index.js"; +import { createAgentCapabilities, createDiagnostic, createDocumentFiles, createSkillFiles } from "../common.js"; +import type { AdapterCompileResult, RuntimeAdapter } from "../types.js"; + +import { + createDaimonContainerTargets, + DAIMON_CONFIG_FILE, + DAIMON_CONTROL_PORT, + DAIMON_ENGINES, + resolveDaimonEngine +} from "./config.js"; +import { prepareDaimonRuntimeAuth } from "./runAuth.js"; + +const assertDaimonSurfaces = (surfaces: ResolvedAgentSurfaces | undefined): void => { + if (!surfaces) return; + const enabled = Object.entries(surfaces) + .filter(([name]) => name !== "moltnet") + .filter(([, value]) => Array.isArray(value) ? value.length > 0 : value !== undefined) + .map(([name]) => name); + if (enabled.length > 0) { + throw new SpawnfileError( + "validation_error", + `Daimon organization runtime v1 only lowers Moltnet agent surfaces; remove: ${enabled.sort().join(", ")}` + ); + } +}; + +const assertDaimonModel = (target: EffectiveModelTarget): void => { + if (target.provider === "openai" && target.auth.method === "codex" && !target.endpoint) return; + throw new SpawnfileError( + "validation_error", + "Daimon organization runtime v1 accepts only the optional OpenAI Codex subscription intent; Grok and AGY engine auth stays Daimon-owned" + ); +}; + +const unsupportedAgentFeatures = (node: ResolvedAgentNode): void => { + if (node.mcpServers.length > 0) { + throw new SpawnfileError("validation_error", "Daimon organization runtime v1 does not lower MCP declarations yet"); + } + if (node.schedule && node.schedule.kind !== "disabled") { + throw new SpawnfileError("validation_error", "Daimon organization runtime v1 does not lower schedules yet"); + } + if (resolveDaimonEngine(node) !== "codex" && node.execution?.model) { + throw new SpawnfileError( + "validation_error", + "Daimon Grok and AGY agents must omit Spawnfile execution.model; their subscription auth and model selection are Daimon-owned" + ); + } +}; + +export const daimonAdapter: RuntimeAdapter = { + assertSupportedModelTarget: assertDaimonModel, + assertSupportedSurfaces: assertDaimonSurfaces, + container: { + configFileName: DAIMON_CONFIG_FILE, + configPathEnv: "SPAWNFILE_DAIMON_CONFIG", + env: [{ + description: "Bearer token for the Daimon organization control API", + generated: true, + name: "SPAWNFILE_DAIMON_CONTROL_TOKEN", + required: true + }], + instancePaths: { + configPathTemplate: "/daimon/", + sourceWorkspacePathTemplate: "/workspace/agents/", + workspacePathTemplate: "/workspace" + }, + port: DAIMON_CONTROL_PORT, + portEnv: "SPAWNFILE_DAIMON_CONTROL_PORT", + standaloneBaseImage: "node:24-bookworm-slim", + startCommand: ["bash", "/daimon-start.sh"], + systemDeps: [ + "bash", + "ca-certificates", + "curl", + "dbus-daemon", + "gnome-keyring", + "util-linux" + ] + }, + async compileAgent(node): Promise { + unsupportedAgentFeatures(node); + return { + capabilities: createAgentCapabilities(node, { + memoryMessage: "Daimon organization runtime v1 does not lower Spawnfile memory declarations yet", + memoryOutcome: "degraded", + scheduleOutcome: node.schedule ? "degraded" : undefined + }), + diagnostics: node.execution?.sandbox + ? [createDiagnostic("warn", "Daimon runtime isolation is enforced by the selected runtime image")] + : [], + files: [ + ...createDocumentFiles("workspace", node.docs), + ...createSkillFiles("workspace/skills", node.skills) + ] + }; + }, + createContainerTargets: createDaimonContainerTargets, + name: "daimon", + prepareRuntimeAuth: prepareDaimonRuntimeAuth, + systemInstructionSurface: { + placement: "append_pointer", + resolvePath() { + return "workspace/AGENTS.md"; + } + }, + validateRuntimeOptions(options) { + const diagnostics = []; + if (options.engine !== undefined && + (typeof options.engine !== "string" || !(DAIMON_ENGINES as readonly string[]).includes(options.engine))) { + diagnostics.push(createDiagnostic("error", `Daimon runtime option engine must be one of ${DAIMON_ENGINES.join(", ")}`)); + } + for (const key of Object.keys(options).filter((key) => key !== "engine" && key !== "restrict_to_workspace")) { + diagnostics.push(createDiagnostic("error", `Daimon runtime option ${key} is not part of organization runtime v1`)); + } + return diagnostics; + } +}; diff --git a/src/runtime/daimon/config.ts b/src/runtime/daimon/config.ts new file mode 100644 index 00000000..3140edfa --- /dev/null +++ b/src/runtime/daimon/config.ts @@ -0,0 +1,156 @@ +import path from "node:path"; + +import type { ResolvedAgentNode } from "../../compiler/types.js"; +import { SpawnfileError } from "../../shared/index.js"; +import type { ContainerTarget, ContainerTargetInput, EmittedFile } from "../types.js"; + +import { + DAIMON_AGY_SUBSCRIPTION_REALM, + DAIMON_ENGINE_CREDENTIALS +} from "./contractManifest.js"; + +export const DAIMON_CONFIG_FILE = "daimon-organization-runtime.json"; +export const DAIMON_CONTROL_PORT = 19700; +export const DAIMON_MAX_AGENTS = 32; +export const DAIMON_ORGANIZATION_TARGET_ID = "daimon-organization"; +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; +export const DAIMON_ENGINES = ["agy", "codex", "grok"] as const; +type DaimonEngine = typeof DAIMON_ENGINES[number]; + +const formatInstructions = (node: ResolvedAgentNode): string => + node.docs.map((document) => `# ${document.role}\n\n${document.content}`).join("\n\n").trim() || + `You are ${node.name}. Follow the workspace instructions.`; + +const assertPublicInstructionBounds = (agentId: string, instructions: string): void => { + if ( + Buffer.byteLength(instructions, "utf8") > DAIMON_MAX_INSTRUCTION_BYTES || + [...instructions].length > DAIMON_MAX_INSTRUCTION_CODEPOINTS + ) { + throw new SpawnfileError( + "validation_error", + `Daimon organization runtime v1 instructions for ${agentId} exceed Daimon's public config limit` + ); + } +}; + +export const resolveDaimonEngine = (node: ResolvedAgentNode): DaimonEngine => { + const engine = node.runtime.options.engine ?? "codex"; + if (typeof engine === "string" && (DAIMON_ENGINES as readonly string[]).includes(engine)) { + return engine as DaimonEngine; + } + throw new SpawnfileError( + "validation_error", + `Daimon runtime option engine must be one of ${DAIMON_ENGINES.join(", ")}` + ); +}; + +const moveWorkspaceFile = (file: EmittedFile, slug: string): EmittedFile => + file.path.startsWith("workspace/") + ? { ...file, path: path.posix.join("workspace", "agents", slug, file.path.slice("workspace/".length)) } + : file; + +const renderStartScript = (agents: Array<{ + engine: { kind: DaimonEngine }; + runtimeHomePath: string; + workspacePath: string; +}>): string => { + const setup = agents.flatMap((agent) => { + const credential = agent.engine.kind === "agy" + ? undefined + : DAIMON_ENGINE_CREDENTIALS[agent.engine.kind]; + const inbound = path.posix.join(agent.runtimeHomePath, ".daimon-inbound"); + return [ + `install -d -m 700 ${[ + agent.workspacePath, + agent.runtimeHomePath, + ...(credential === undefined ? [] : [inbound]) + ].map((entry) => JSON.stringify(entry)).join(" ")}`, + ...(credential === undefined ? [] : [ + `if [ -e ${JSON.stringify(path.posix.join(agent.runtimeHomePath, credential.sourceRelativePath))} ]; then test "$(stat -c %a ${JSON.stringify(path.posix.join(agent.runtimeHomePath, credential.sourceRelativePath))})" = 600; fi` + ]) + ]; + }); + return [ + "#!/usr/bin/env bash", + "set -euo pipefail", + ...setup, + 'if [ "$#" -gt 0 ]; then exec daimon-runtime "$@"; fi', + "exec daimon-runtime run --config " + ].join("\n") + "\n"; +}; + +export const createDaimonContainerTargets = async ( + inputs: ContainerTargetInput[] +): Promise => { + const agents = inputs.filter( + (input): input is ContainerTargetInput & { value: ResolvedAgentNode } => + input.kind === "agent" && input.value.kind === "agent" + ); + if (agents.length === 0) return []; + if (agents.length > DAIMON_MAX_AGENTS) { + throw new SpawnfileError( + "validation_error", + `Daimon organization runtime v1 supports at most 32 agents; found ${agents.length}. Split the organization across explicit runtime boundaries.` + ); + } + + const configAgents = agents + .map((input) => ({ + engine: { kind: resolveDaimonEngine(input.value) }, + id: input.id, + instructions: formatInstructions(input.value), + name: input.value.name, + runtimeHomePath: `/${DAIMON_RUNTIME_HOMES_DIRECTORY}/${input.slug}`, + workspacePath: `/agents/${input.slug}` + })) + .sort((left, right) => left.id.localeCompare(right.id)); + const engineByNodeId = Object.fromEntries(configAgents.map((agent) => [agent.id, agent.engine.kind])); + const hasAgy = configAgents.some((agent) => agent.engine.kind === "agy"); + const agyRuntimeHomeMounts = configAgents + .filter((agent) => agent.engine.kind === "agy") + .map((agent) => ({ + id: `daimon-agy-runtime-home-${path.posix.basename(agent.runtimeHomePath)}`, + mountPath: agent.runtimeHomePath, + reason: `Daimon AGY subscription runtime home for ${agent.id}` + })); + for (const agent of configAgents) assertPublicInstructionBounds(agent.id, agent.instructions); + const config = { + agents: configAgents, + host: { + bindHost: "127.0.0.1", + controlTokenEnv: "SPAWNFILE_DAIMON_CONTROL_TOKEN", + port: DAIMON_CONTROL_PORT + }, + version: "noopolis.daimon.organization-runtime.v1" + }; + const serializedConfig = `${JSON.stringify(config, null, 2)}\n`; + if (Buffer.byteLength(serializedConfig, "utf8") > DAIMON_MAX_CONFIG_BYTES) { + throw new SpawnfileError("validation_error", "Daimon organization runtime v1 config exceeds Daimon's public config limit"); + } + + return [{ + engineByNodeId, + files: [ + ...agents.flatMap((input) => input.emittedFiles.map((file) => moveWorkspaceFile(file, input.slug))), + { content: serializedConfig, path: DAIMON_CONFIG_FILE }, + { + content: renderStartScript(configAgents), + mode: 0o755, + path: "runtime/daimon-start.sh" + } + ], + id: DAIMON_ORGANIZATION_TARGET_ID, + ...(hasAgy ? { + opaqueMountTargets: [DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath], + persistentMounts: [{ + id: "daimon-agy-subscription-realm", + mountPath: DAIMON_AGY_SUBSCRIPTION_REALM.durableMountPath, + reason: "Daimon host AGY subscription realm" + }, ...agyRuntimeHomeMounts] + } : {}), + sourceIds: agents.map((agent) => agent.id).sort() + }]; +}; diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json new file mode 100644 index 00000000..f12052ac --- /dev/null +++ b/src/runtime/daimon/contract-manifest.json @@ -0,0 +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"},"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"],"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"},"grok":{"destinationRelativePath":".grok/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/grok-auth","sourceSlot":"grok-auth"}},"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"},"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"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v1","wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","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":{"maximum":180000,"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 new file mode 100644 index 00000000..5e3a1572 --- /dev/null +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -0,0 +1 @@ +d31ebbda8b720fa1c20b3cfc11aec1bfc04ae4b95e4becf2f76f681f150a60d8 diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts new file mode 100644 index 00000000..9a8652fd --- /dev/null +++ b/src/runtime/daimon/contractManifest.test.ts @@ -0,0 +1,123 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { removeDirectory } from "../../filesystem/index.js"; +import { + assertDaimonRuntimeHome, + DAIMON_CONTRACT_MANIFEST_DIGEST_FILE, + DAIMON_CONTRACT_MANIFEST_FILE, + DAIMON_CONTRACT_MANIFEST_VERSION, + parseDaimonContractManifest, + readVerifiedDaimonContractManifest +} from "./contractManifest.js"; + +const temporaryDirectories: string[] = []; +const canonical = (value: unknown): string => { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; +}; +const manifest = () => ({ + agySubscriptionRealm: { + directoryMode: 0o700, + durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + fileMode: 0o600, + maxUnlockBytes: 4_096, + 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" + ], + engineCredentialMaterial: { + codex: { destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" }, + grok: { destinationRelativePath: ".grok/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/grok-auth", sourceSlot: "grok-auth" } + }, + supportedEngineKinds: ["agy", "codex", "grok"], + version: DAIMON_CONTRACT_MANIFEST_VERSION +}); + +const writeManifest = async (source: string): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-contract-")); + temporaryDirectories.push(directory); + await writeFile(path.join(directory, DAIMON_CONTRACT_MANIFEST_FILE), source); + await writeFile( + path.join(directory, DAIMON_CONTRACT_MANIFEST_DIGEST_FILE), + `sha256:${createHash("sha256").update(source).digest("hex")}\n` + ); + return directory; +}; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(removeDirectory)); +}); + +describe("Daimon contract manifest", () => { + it("accepts canonical source-free contract bytes", async () => { + const source = `${canonical(manifest())}\n`; + await expect(readVerifiedDaimonContractManifest(await writeManifest(source))).resolves.toMatchObject({ + manifest: { + agySubscriptionRealm: { + durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret" + }, + supportedEngineKinds: ["agy", "codex", "grok"] + } + }); + }); + + it.each([ + "{\"supportedEngineKinds\":[\"agy\",\"codex\",\"grok\"]}\n", + `${JSON.stringify(manifest(), null, 2)}\n`, + `${canonical({ ...manifest(), engineCredentialMaterial: { ...manifest().engineCredentialMaterial, codex: { destinationRelativePath: ".codex/auth.json", directoryMode: 0o755, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" } } })}\n` + ])("rejects malformed, noncanonical, or unsafe bytes", async (source) => { + await expect(readVerifiedDaimonContractManifest(await writeManifest(source))).rejects.toThrow(/manifest/u); + }); + + it("rejects later traversal escapes before any runtime-home join", () => { + expect(assertDaimonRuntimeHome("/var/lib/spawnfile/instances/daimon/org/runtime-homes/a")) + .toBe("/var/lib/spawnfile/instances/daimon/org/runtime-homes/a"); + for (const unsafe of [ + "/var/lib/spawnfile/instances/daimon/../other", + "/var/lib/spawnfile/instances/daimon/org/runtime-homes/a/../../../../escape", + "/tmp/daimon-home" + ]) expect(() => assertDaimonRuntimeHome(unsafe)).toThrow(/escapes/u); + expect(() => assertDaimonRuntimeHome("relative/runtime-home")).toThrow(/absolute POSIX/u); + }); + + it("rejects malformed credential and AGY material at the consumed contract boundary", () => { + for (const invalid of [null, [], "manifest"]) { + expect(() => parseDaimonContractManifest(invalid)).toThrow(/manifest/u); + } + expect(() => parseDaimonContractManifest({ + ...manifest(), + engineCredentialMaterial: { ...manifest().engineCredentialMaterial, extra: {} } + })).toThrow(/credential material/u); + expect(() => parseDaimonContractManifest({ + ...manifest(), + agySubscriptionRealm: { ...manifest().agySubscriptionRealm, directoryMode: 0o755 } + })).toThrow(/AGY subscription realm/u); + }); + + it("rejects missing, malformed, noncanonical, and digest-mismatched packaged files", async () => { + const missing = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-contract-missing-")); + temporaryDirectories.push(missing); + await expect(readVerifiedDaimonContractManifest(missing)).rejects.toThrow(/missing/u); + + await expect(readVerifiedDaimonContractManifest(await writeManifest("{not-json}\n"))) + .rejects.toThrow(/valid JSON/u); + await expect(readVerifiedDaimonContractManifest(await writeManifest(`${canonical(manifest())}\r\n`))) + .rejects.toThrow(/canonical UTF-8/u); + + const mismatched = await writeManifest(`${canonical(manifest())}\n`); + await writeFile(path.join(mismatched, DAIMON_CONTRACT_MANIFEST_DIGEST_FILE), `sha256:${"0".repeat(64)}\n`); + await expect(readVerifiedDaimonContractManifest(mismatched)).rejects.toThrow(/digest sidecar/u); + }); +}); diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts new file mode 100644 index 00000000..253ff220 --- /dev/null +++ b/src/runtime/daimon/contractManifest.ts @@ -0,0 +1,167 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { SpawnfileError } from "../../shared/index.js"; + +export const DAIMON_CONTRACT_MANIFEST_VERSION = + "noopolis.daimon.runtime-contract-manifest.v1" 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"; +export const DAIMON_ENGINE_KINDS = ["agy", "codex", "grok"] as const; +export const DAIMON_ENGINE_CREDENTIALS = { + codex: { + destinationRelativePath: ".codex/auth.json", + directoryMode: 0o700, + fileMode: 0o600, + sourceRelativePath: ".daimon-inbound/codex-auth", + sourceSlot: "codex-auth" + }, + grok: { + destinationRelativePath: ".grok/auth.json", + directoryMode: 0o700, + fileMode: 0o600, + sourceRelativePath: ".daimon-inbound/grok-auth", + sourceSlot: "grok-auth" + } +} as const; +export const DAIMON_AGY_SUBSCRIPTION_REALM = { + directoryMode: 0o700, + durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + fileMode: 0o600, + maxUnlockBytes: 4_096, + unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret", + unlockSourceSlot: "agy-unlock-secret" +} as const; + +export type DaimonEngine = typeof DAIMON_ENGINE_KINDS[number]; +export type DaimonPortableEngine = keyof typeof DAIMON_ENGINE_CREDENTIALS; +type DaimonCredentialMaterial = (typeof DAIMON_ENGINE_CREDENTIALS)[DaimonPortableEngine]; + +export interface DaimonContractManifest { + readonly agySubscriptionRealm: typeof DAIMON_AGY_SUBSCRIPTION_REALM; + readonly consumedConfigFields: readonly string[]; + readonly engineCredentialMaterial: Readonly>; + readonly supportedEngineKinds: readonly DaimonEngine[]; + readonly version: typeof DAIMON_CONTRACT_MANIFEST_VERSION; +} + +export interface VerifiedDaimonContractManifest { + readonly digest: `sha256:${string}`; + readonly manifest: DaimonContractManifest; +} + +const SHA256 = /^[a-f0-9]{64}$/u; +const expectedConfigFields = [ + "version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", + "agents[].name", "agents[].instructions", "agents[].workspacePath", + "agents[].runtimeHomePath", "agents[].engine.kind" +] as const; +const exactKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); + +const fail = (message: string): never => { + throw new SpawnfileError("runtime_error", `Daimon runtime contract manifest ${message}`); +}; + +const asRecord = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label} must be an object`); + return value as Record; +}; + +const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const record = asRecord(value, "JSON value"); + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; +}; + +const matchesCredentialMaterial = ( + value: unknown, + expected: DaimonCredentialMaterial +): value is DaimonCredentialMaterial => { + const material = asRecord(value, "engine credential material"); + return exactKeys(material, ["destinationRelativePath", "directoryMode", "fileMode", "sourceRelativePath", "sourceSlot"]) + && material.destinationRelativePath === expected.destinationRelativePath + && material.directoryMode === expected.directoryMode + && material.fileMode === expected.fileMode + && material.sourceRelativePath === expected.sourceRelativePath + && material.sourceSlot === expected.sourceSlot; +}; + +const matchesAgyRealm = (value: unknown): value is typeof DAIMON_AGY_SUBSCRIPTION_REALM => { + const realm = asRecord(value, "AGY subscription realm"); + return exactKeys(realm, [ + "directoryMode", "durableMountPath", "fileMode", "maxUnlockBytes", + "unlockMountPath", "unlockSourceSlot" + ]) + && Object.entries(DAIMON_AGY_SUBSCRIPTION_REALM) + .every(([name, expected]) => realm[name] === expected); +}; + +export const parseDaimonContractManifest = (raw: unknown): DaimonContractManifest => { + const root = asRecord(raw, "root"); + if ( + root.version !== DAIMON_CONTRACT_MANIFEST_VERSION || + !Array.isArray(root.supportedEngineKinds) || + root.supportedEngineKinds.join("\0") !== "agy\0codex\0grok" || + !Array.isArray(root.consumedConfigFields) || + root.consumedConfigFields.join("\0") !== expectedConfigFields.join("\0") + ) return fail("has an unsupported version or configuration contract"); + const materials = asRecord(root.engineCredentialMaterial, "engineCredentialMaterial"); + if (!exactKeys(materials, ["codex", "grok"])) return fail("has unsupported credential material"); + for (const engine of ["codex", "grok"] as const) { + if (!matchesCredentialMaterial(materials[engine], DAIMON_ENGINE_CREDENTIALS[engine])) { + return fail(`has unsafe ${engine} credential material`); + } + } + if (!matchesAgyRealm(root.agySubscriptionRealm)) { + return fail("has unsafe AGY subscription realm material"); + } + return Object.freeze({ + agySubscriptionRealm: Object.freeze({ ...DAIMON_AGY_SUBSCRIPTION_REALM }), + consumedConfigFields: Object.freeze([...expectedConfigFields]), + engineCredentialMaterial: Object.freeze({ ...DAIMON_ENGINE_CREDENTIALS }), + supportedEngineKinds: Object.freeze([...DAIMON_ENGINE_KINDS]), + version: DAIMON_CONTRACT_MANIFEST_VERSION + }); +}; + +export const assertDaimonRuntimeHome = (candidate: string): string => { + if (!path.posix.isAbsolute(candidate)) fail("runtime home must be an absolute POSIX path"); + const normalized = path.posix.normalize(candidate); + const relative = path.posix.relative(DAIMON_RUNTIME_HOME_ROOT, normalized); + if (!relative || relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) { + fail("runtime home escapes the caller-owned Daimon root"); + } + return normalized; +}; + +export const readVerifiedDaimonContractManifest = async ( + runtimeRoot: string +): Promise => { + const manifestPath = path.join(runtimeRoot, DAIMON_CONTRACT_MANIFEST_FILE); + const digestPath = path.join(runtimeRoot, DAIMON_CONTRACT_MANIFEST_DIGEST_FILE); + let bytes: Buffer; + let sidecar: string; + try { + [bytes, sidecar] = await Promise.all([readFile(manifestPath), readFile(digestPath, "utf8")]); + } catch { + return fail("is missing its packaged bytes or digest sidecar"); + } + const source = bytes.toString("utf8"); + if (!source.endsWith("\n") || source.includes("\r")) return fail("is not canonical UTF-8 JSON"); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return fail("is not valid JSON"); + } + if (`${canonicalJson(parsed)}\n` !== source) return fail("is not canonical JSON"); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (sidecar !== `sha256:${digest}\n` || !SHA256.test(digest)) return fail("digest sidecar does not match its bytes"); + return Object.freeze({ digest: `sha256:${digest}`, manifest: parseDaimonContractManifest(parsed) }); +}; diff --git a/src/runtime/daimon/runAuth.test.ts b/src/runtime/daimon/runAuth.test.ts new file mode 100644 index 00000000..58453f3d --- /dev/null +++ b/src/runtime/daimon/runAuth.test.ts @@ -0,0 +1,271 @@ +import { chmod, lstat, mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { removeDirectory } from "../../filesystem/index.js"; +import { prepareDaimonRuntimeAuth } from "./runAuth.js"; + +const temporaryDirectories: string[] = []; +const originalCodexHome = process.env.CODEX_HOME; +const originalGrokHome = process.env.GROK_HOME; +const createTempDirectory = async (prefix: string): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +}; +const writeConfig = async (outputDirectory: string, home: string): Promise => { + const configPath = "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json"; + const hostPath = path.join(outputDirectory, "container", "rootfs", `.${configPath}`); + await mkdir(path.dirname(hostPath), { recursive: true }); + await writeFile(hostPath, JSON.stringify({ + agents: [{ engine: { kind: "codex" }, id: "agent:codex", runtimeHomePath: home }], + host: {}, + version: "noopolis.daimon.organization-runtime.v1" + })); + return configPath; +}; +const writeConfigSource = async ( + outputDirectory: string, + source: string, + configPath = "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json" +): Promise => { + const hostPath = path.join(outputDirectory, "container", "rootfs", `.${configPath}`); + await mkdir(path.dirname(hostPath), { recursive: true }); + await writeFile(hostPath, source); + return configPath; +}; +const prepare = (outputDirectory: string, tempRoot: string, configPath: string) => + prepareDaimonRuntimeAuth({ + authProfile: null, + env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, + tempRoot + }); + +afterEach(async () => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + if (originalGrokHome === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = originalGrokHome; + delete process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH; + delete process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH; + delete process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; + delete process.env.SPAWNFILE_DAIMON_SOURCE_UNKNOWN; + await Promise.all(temporaryDirectories.splice(0).map(removeDirectory)); +}); + +describe("prepareDaimonRuntimeAuth", () => { + it("binds only one selected 0600 credential leaf without materializing it", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const codexHome = await createTempDirectory("spawnfile-daimon-codex-"); + process.env.CODEX_HOME = codexHome; + await writeFile(path.join(codexHome, "auth.json"), "{\"token\":\"redacted\"}\n"); + await chmod(path.join(codexHome, "auth.json"), 0o600); + const home = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex"; + const configPath = await writeConfig(outputDirectory, home); + const prepared = await prepareDaimonRuntimeAuth({ + authProfile: null, + env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, + tempRoot + }); + const mount = prepared.mountArgs[1]!; + const source = path.join(codexHome, "auth.json"); + expect(mount).toBe(`${source}:${home}/.daimon-inbound/codex-auth:ro`); + expect(prepared.launchIdentity).toEqual({ kind: "daimon", uid: process.getuid?.() }); + expect(prepared.mountArgs.join("\n")).not.toContain(tempRoot); + expect((await lstat(path.join(outputDirectory, "container", "rootfs", `.${home}`, ".daimon-inbound"))).mode & 0o777) + .toBe(0o700); + }); + + it("rejects an insecure caller-provided credential source", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const codexHome = await createTempDirectory("spawnfile-daimon-codex-"); + process.env.CODEX_HOME = codexHome; + await writeFile(path.join(codexHome, "auth.json"), "token\n"); + const configPath = await writeConfig( + outputDirectory, + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex" + ); + await expect(prepareDaimonRuntimeAuth({ + authProfile: null, + env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, + tempRoot + })).rejects.toThrow(/0600/u); + }); + + it("binds portable leaves plus one independent AGY realm unlock source", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const credentials = await Promise.all(["codex", "grok"].map(async (engine) => { + const home = await createTempDirectory(`spawnfile-daimon-${engine}-`); + const file = "auth.json"; + await writeFile(path.join(home, file), `${engine}-token\n`); + await chmod(path.join(home, file), 0o600); + return { engine, file, home }; + })); + process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH = path.join( + credentials[0]!.home, + credentials[0]!.file + ); + process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH = path.join( + credentials[1]!.home, + credentials[1]!.file + ); + const unlock = path.join(outputDirectory, "agy-unlock"); + await writeFile(unlock, "opaque-unlock"); + await chmod(unlock, 0o600); + process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = unlock; + const configPath = "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json"; + const hostPath = path.join(outputDirectory, "container", "rootfs", `.${configPath}`); + await mkdir(path.dirname(hostPath), { recursive: true }); + await writeFile(hostPath, JSON.stringify({ + agents: [...credentials.map((credential) => ({ + engine: { kind: credential.engine }, + id: `agent:${credential.engine}`, + runtimeHomePath: `/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/${credential.engine}` + })), { + engine: { kind: "agy" }, + id: "agent:agy", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" + }], + host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + + const prepared = await prepareDaimonRuntimeAuth({ + authProfile: null, env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, tempRoot + }); + + expect(prepared.mountArgs).toEqual([...credentials].sort((left, right) => + left.engine.localeCompare(right.engine) + ).flatMap((credential) => [ + "-v", + `${path.join(credential.home, credential.file)}:/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/${credential.engine}/.daimon-inbound/${credential.engine}-auth:ro` + ]).concat(["-v", `${unlock}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro`])); + expect(prepared.launchIdentity).toEqual({ kind: "daimon", uid: process.getuid?.() }); + expect(prepared.mountArgs.join("\n")).not.toContain("antigravity-oauth-token"); + }); + + it("fails closed when an AGY organization has no unlock source", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const configPath = "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json"; + const hostPath = path.join(outputDirectory, "container", "rootfs", `.${configPath}`); + await mkdir(path.dirname(hostPath), { recursive: true }); + await writeFile(hostPath, JSON.stringify({ + agents: [{ + engine: { kind: "agy" }, + id: "agent:agy", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" + }], + host: {}, + version: "noopolis.daimon.organization-runtime.v1" + })); + + await expect(prepareDaimonRuntimeAuth({ + authProfile: null, + env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, + tempRoot + })).rejects.toThrow(/AGY realm unlock/u); + }); + + it("fails before staging a later runtime-home traversal", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const configPath = await writeConfig( + outputDirectory, + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/a/../../../../escape" + ); + await expect(prepareDaimonRuntimeAuth({ + authProfile: null, + env: {}, + instance: { config_path: configPath, home_path: null, id: "daimon-organization", model_auth_methods: {}, model_secrets_required: [], runtime: "daimon" }, + outputDirectory, + tempRoot + })).rejects.toThrow(/escapes/u); + }); + + it("accepts an auth-free organization and resolves Grok from its native home", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const emptyConfig = await writeConfigSource(outputDirectory, JSON.stringify({ + agents: [], host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + await expect(prepare(outputDirectory, tempRoot, emptyConfig)).resolves.toEqual({ + coveredModelSecrets: [], mountArgs: [] + }); + + const grokHome = await createTempDirectory("spawnfile-daimon-grok-home-"); + process.env.GROK_HOME = grokHome; + await writeFile(path.join(grokHome, "auth.json"), "grok-auth\n"); + await chmod(path.join(grokHome, "auth.json"), 0o600); + const grokConfig = await writeConfigSource(outputDirectory, JSON.stringify({ + agents: [{ + engine: { kind: "grok" }, id: "agent:grok", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok" + }], + host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + await expect(prepare(outputDirectory, tempRoot, grokConfig)).resolves.toMatchObject({ + launchIdentity: { kind: "daimon", uid: process.getuid?.() }, + mountArgs: ["-v", expect.stringContaining(".daimon-inbound/grok-auth:ro")] + }); + }); + + it("rejects undeclared source slots and unsafe generated config shapes before mounting", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + process.env.SPAWNFILE_DAIMON_SOURCE_UNKNOWN = "/private/source"; + await expect(prepare(outputDirectory, tempRoot, "/missing.json")) + .rejects.toThrow(/not declared/u); + delete process.env.SPAWNFILE_DAIMON_SOURCE_UNKNOWN; + + await expect(prepare(outputDirectory, tempRoot, "relative.json")) + .rejects.toThrow(/non-absolute/u); + await expect(prepare(outputDirectory, tempRoot, "/")) + .rejects.toThrow(/outside its ephemeral support root/u); + await expect(prepare(outputDirectory, tempRoot, "/missing.json")) + .rejects.toThrow(/could not read/u); + + const home = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agent"; + const invalidSources = [ + ["not-json", /not JSON/u], + ["null", /invalid shape/u], + [JSON.stringify({ agents: {}, version: "noopolis.daimon.organization-runtime.v1" }), /not v1/u], + [JSON.stringify({ agents: [null], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent$/u], + [JSON.stringify({ agents: [{ engine: null, id: "agent", runtimeHomePath: home }], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent credential target/u], + [JSON.stringify({ agents: [{ engine: { kind: "codex" }, id: "", runtimeHomePath: home }], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent credential target/u], + [JSON.stringify({ agents: [ + { engine: { kind: "codex" }, id: "a", runtimeHomePath: home }, + { engine: { kind: "grok" }, id: "b", runtimeHomePath: home } + ], version: "noopolis.daimon.organization-runtime.v1" }), /overlapping runtime homes/u] + ] as const; + for (const [source, message] of invalidSources) { + const configPath = await writeConfigSource(outputDirectory, source); + await expect(prepare(outputDirectory, tempRoot, configPath)).rejects.toThrow(message); + } + }); + + it("fails closed when a selected native credential leaf is missing", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + process.env.CODEX_HOME = await createTempDirectory("spawnfile-daimon-empty-codex-"); + const configPath = await writeConfig( + outputDirectory, + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex" + ); + await expect(prepare(outputDirectory, tempRoot, configPath)).rejects.toThrow(/missing the selected codex/u); + }); +}); diff --git a/src/runtime/daimon/runAuth.ts b/src/runtime/daimon/runAuth.ts new file mode 100644 index 00000000..c2a269ea --- /dev/null +++ b/src/runtime/daimon/runAuth.ts @@ -0,0 +1,219 @@ +import os from "node:os"; +import path from "node:path"; +import { chmod, lstat, mkdir, readFile } from "node:fs/promises"; + +import { SpawnfileError } from "../../shared/index.js"; +import type { RuntimeAuthPreparationInput, RuntimeAuthPreparationResult } from "../types.js"; + +import { + DAIMON_AGY_SUBSCRIPTION_REALM, + assertDaimonRuntimeHome, + DAIMON_ENGINE_CREDENTIALS, + DAIMON_ENGINE_KINDS, + type DaimonEngine +} from "./contractManifest.js"; + +const MAX_OPAQUE_CREDENTIAL_BYTES = 64 * 1024; +export const DAIMON_AGY_UNLOCK_SOURCE_ENV = "SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET"; + +interface DaimonConfigAgent { + engine: { kind: DaimonEngine }; + id: string; + runtimeHomePath: string; +} + +const DAIMON_CONFIG_VERSION = "noopolis.daimon.organization-runtime.v1"; + +const fail = (message: string): never => { + throw new SpawnfileError("validation_error", `Daimon runtime auth ${message}`); +}; + +const sourceEnvironmentName = (slot: string): string => + `SPAWNFILE_DAIMON_SOURCE_${slot.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`; + +const sourcePathForEngine = (engine: Exclude): string => { + const declaredSource = process.env[ + sourceEnvironmentName(DAIMON_ENGINE_CREDENTIALS[engine].sourceSlot) + ]?.trim(); + if (declaredSource) return declaredSource; + const home = os.homedir(); + switch (engine) { + case "codex": + return path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "auth.json"); + case "grok": + return path.join(process.env.GROK_HOME || path.join(home, ".grok"), "auth.json"); + } +}; + +const assertSafeSourceFile = async ( + sourcePath: string, + label: string, + maxBytes = MAX_OPAQUE_CREDENTIAL_BYTES +): Promise => { + let entry: Awaited>; + try { + entry = await lstat(sourcePath); + } catch { + return fail(`is missing the selected ${label} artifact`); + } + const callerUid = process.getuid?.(); + if ( + !entry.isFile() || + entry.isSymbolicLink() || + entry.size === 0 || + entry.size > maxBytes || + entry.nlink !== 1 || + (entry.mode & 0o777) !== 0o600 || + typeof callerUid !== "number" || + callerUid <= 0 || + entry.uid !== callerUid + ) { + return fail(`selected ${label} artifact must be one bounded caller-owned 0600 regular file`); + } + return callerUid; +}; + +const assertContainedPath = (root: string, candidate: string): string => { + const normalizedRoot = path.resolve(root); + const normalizedCandidate = path.resolve(candidate); + const relative = path.relative(normalizedRoot, normalizedCandidate); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return fail("attempted to stage outside its ephemeral support root"); + } + return normalizedCandidate; +}; + +const configPathInOutput = (input: RuntimeAuthPreparationInput): string => { + if (!input.instance.config_path.startsWith("/")) fail("has a non-absolute generated config path"); + return assertContainedPath( + path.join(input.outputDirectory, "container", "rootfs"), + path.join(input.outputDirectory, "container", "rootfs", `.${input.instance.config_path}`) + ); +}; + +const parseConfigAgents = (source: string): DaimonConfigAgent[] => { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return fail("generated organization config is not JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) fail("generated organization config has an invalid shape"); + const root = parsed as Record; + if (root.version !== DAIMON_CONFIG_VERSION || !Array.isArray(root.agents)) fail("generated organization config is not v1"); + const rawAgents = root.agents as unknown[]; + const seenHomes = new Set(); + const agents: DaimonConfigAgent[] = []; + for (const value of rawAgents) { + if (!value || typeof value !== "object" || Array.isArray(value)) fail("generated organization config has an invalid agent"); + const agent = value as Record; + const engine = agent.engine; + const engineKind = engine && typeof engine === "object" && !Array.isArray(engine) + ? (engine as Record).kind + : undefined; + if (typeof agent.id !== "string" || !agent.id || typeof agent.runtimeHomePath !== "string" + || typeof engineKind !== "string" + || !(DAIMON_ENGINE_KINDS as readonly string[]).includes(engineKind)) { + fail("generated organization config has an invalid agent credential target"); + } + const agentId = agent.id as string; + const runtimeHomePath = assertDaimonRuntimeHome(agent.runtimeHomePath as string); + if (seenHomes.has(runtimeHomePath)) fail("generated organization config has overlapping runtime homes"); + seenHomes.add(runtimeHomePath); + agents.push({ + engine: { kind: engineKind as DaimonEngine }, + id: agentId, + runtimeHomePath + }); + } + return agents.sort((left, right) => left.id.localeCompare(right.id)); +}; + +const resolveCredentialSource = async ( + agent: DaimonConfigAgent & { engine: { kind: Exclude } } +): Promise => { + return sourcePathForEngine(agent.engine.kind); +}; + +const prepareNeutralIngress = async ( + outputDirectory: string, + agent: DaimonConfigAgent & { engine: { kind: Exclude } } +): Promise => { + const rootfs = path.join(outputDirectory, "container", "rootfs"); + const runtimeHome = assertContainedPath(rootfs, path.join(rootfs, `.${agent.runtimeHomePath}`)); + const inbound = assertContainedPath( + runtimeHome, + path.join(runtimeHome, path.posix.dirname(DAIMON_ENGINE_CREDENTIALS[agent.engine.kind].sourceRelativePath)) + ); + await mkdir(runtimeHome, { mode: 0o700, recursive: true }); + await chmod(runtimeHome, 0o700); + await mkdir(inbound, { mode: 0o700, recursive: true }); + await chmod(inbound, 0o700); + return path.posix.join(agent.runtimeHomePath, DAIMON_ENGINE_CREDENTIALS[agent.engine.kind].sourceRelativePath); +}; + +/** + * Binds one declared credential leaf per Daimon agent without copying or + * reading its contents. The read-only mount is a generic private ingress; + * Daimon is solely responsible for consuming it into its runtime-owned home. + */ +export const prepareDaimonRuntimeAuth = async ( + input: RuntimeAuthPreparationInput +): Promise => { + const allowedSourceEnvironments = new Set([ + ...Object.values(DAIMON_ENGINE_CREDENTIALS).map((credential) => + sourceEnvironmentName(credential.sourceSlot) + ), + DAIMON_AGY_UNLOCK_SOURCE_ENV + ]); + for (const name of Object.keys(process.env)) { + if (name.startsWith("SPAWNFILE_DAIMON_SOURCE_") && !allowedSourceEnvironments.has(name)) { + return fail(`source slot environment ${name} is not declared by the consumed manifest`); + } + } + const configPath = configPathInOutput(input); + let configSource: string; + try { + configSource = await readFile(configPath, "utf8"); + } catch { + return fail("could not read its generated organization config"); + } + const agents = parseConfigAgents(configSource); + const mountArgs: string[] = []; + let authorizedUid: number | undefined; + for (const agent of agents) { + if (agent.engine.kind === "agy") continue; + const portableAgent = agent as DaimonConfigAgent & { + engine: { kind: Exclude }; + }; + const sourcePath = await resolveCredentialSource(portableAgent); + const ingressPath = await prepareNeutralIngress(input.outputDirectory, portableAgent); + const sourceUid = await assertSafeSourceFile(sourcePath, agent.engine.kind); + if (authorizedUid !== undefined && sourceUid !== authorizedUid) { + return fail("selected credential artifacts must share one authorized UID"); + } + authorizedUid = sourceUid; + mountArgs.push("-v", `${sourcePath}:${ingressPath}:ro`); + } + if (agents.some((agent) => agent.engine.kind === "agy")) { + const source = process.env[DAIMON_AGY_UNLOCK_SOURCE_ENV]?.trim(); + if (!source) return fail("is missing the operator-authorized AGY realm unlock artifact"); + const sourceUid = await assertSafeSourceFile( + source, + "AGY realm unlock", + DAIMON_AGY_SUBSCRIPTION_REALM.maxUnlockBytes + ); + if (authorizedUid !== undefined && sourceUid !== authorizedUid) { + return fail("selected credential artifacts must share one authorized UID"); + } + authorizedUid = sourceUid; + mountArgs.push("-v", `${source}:${DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath}:ro`); + } + return { + coveredModelSecrets: [], + ...(authorizedUid === undefined ? {} : { + launchIdentity: { kind: "daimon" as const, uid: authorizedUid } + }), + mountArgs + }; +}; diff --git a/src/runtime/install.test.ts b/src/runtime/install.test.ts index d5341dbf..264b5e85 100644 --- a/src/runtime/install.test.ts +++ b/src/runtime/install.test.ts @@ -19,14 +19,16 @@ describe("runtime install selection", () => { it("resolves Daimon install selection from the pinned runtime image", async () => { await expect(resolveRuntimeInstallSelection("daimon")).resolves.toEqual({ + capabilityReceipt: "sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7", + digest: "sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa", ecosystem: "node", image: "noopolis/spawnfile-runtime-daimon", installHint: "Copy a pinned Daimon runtime image.", kind: "container_image", runtimeName: "daimon", - runtimeRef: "v0.1.2", + runtimeRef: "v0.2.0", selectionSource: "runtime_registry_install", - tag: "0.1.2" + tag: "0.2.0" }); }); diff --git a/src/runtime/install.ts b/src/runtime/install.ts index cd4d7f88..a3e0dfc8 100644 --- a/src/runtime/install.ts +++ b/src/runtime/install.ts @@ -8,6 +8,8 @@ import { export type RuntimeInstallSelection = | { ecosystem: "go" | "node"; + capabilityReceipt?: string; + digest?: string; image: string; installHint: string; kind: "container_image"; @@ -173,6 +175,8 @@ export const resolveRuntimeInstallSelection = async ( case "container_image": return { ecosystem: installProfile.container_image.ecosystem, + capabilityReceipt: runtime.install.capabilityReceipt, + digest: runtime.install.digest, image: runtime.install.image, installHint: installProfile.container_image.installHint, kind: "container_image", diff --git a/src/runtime/pi/adapter.ts b/src/runtime/pi/adapter.ts index 9ca681f4..f54ec086 100644 --- a/src/runtime/pi/adapter.ts +++ b/src/runtime/pi/adapter.ts @@ -336,9 +336,4 @@ export const piAdapter: RuntimeAdapter = { } }; -export const daimonAdapter: RuntimeAdapter = { - ...piAdapter, - name: "daimon" -}; - export const PI_RUNTIME_PACKAGE = `${PI_PACKAGE_NAME}@${PI_PACKAGE_VERSION}`; diff --git a/src/runtime/registry.ts b/src/runtime/registry.ts index 78e155b7..712f1675 100644 --- a/src/runtime/registry.ts +++ b/src/runtime/registry.ts @@ -8,7 +8,8 @@ import type { RuntimeLifecycleStatus } from "../shared/index.js"; import { SpawnfileError } from "../shared/index.js"; import { openClawAdapter } from "./openclaw/adapter.js"; -import { daimonAdapter, piAdapter } from "./pi/adapter.js"; +import { daimonAdapter } from "./daimon/adapter.js"; +import { piAdapter } from "./pi/adapter.js"; import { picoClawAdapter } from "./picoclaw/adapter.js"; import type { RuntimeAdapter } from "./types.js"; @@ -22,6 +23,8 @@ const runtimeAdapters = new Map([ const runtimeInstallSchema = z.discriminatedUnion("kind", [ z .object({ + capability_receipt: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + digest: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), image: z.string().min(1), kind: z.literal("container_image"), tag: z.string().min(1) @@ -73,6 +76,8 @@ let runtimeRegistryPromise: Promise | undefined; export type RuntimeRegistryInstall = | { + capabilityReceipt?: string; + digest?: string; image: string; kind: "container_image"; tag: string; @@ -109,7 +114,17 @@ export const parseRuntimeRegistry = (source: string): RuntimeRegistryEntry[] => return Object.entries(parsed.runtimes) .map(([name, entry]) => ({ defaultBranch: entry.default_branch, - install: entry.install, + install: entry.install?.kind === "container_image" + ? { + ...(entry.install.capability_receipt + ? { capabilityReceipt: entry.install.capability_receipt } + : {}), + ...(entry.install.digest ? { digest: entry.install.digest } : {}), + image: entry.install.image, + kind: entry.install.kind, + tag: entry.install.tag + } + : entry.install, name, ref: entry.ref, remote: entry.remote, diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 375144b7..e0ed7d0f 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -37,6 +37,12 @@ export interface ContainerTargetEnvFile { relativePath: string; } +export interface ContainerTargetPersistentMount { + id: string; + mountPath: string; + reason: string; +} + export type RuntimeContainerConfigValueTransform = "bearer"; export interface ContainerTarget { @@ -53,6 +59,8 @@ export interface ContainerTarget { envFiles?: ContainerTargetEnvFile[]; files: EmittedFile[]; id: string; + opaqueMountTargets?: string[]; + persistentMounts?: ContainerTargetPersistentMount[]; sourceIds?: string[]; /** World token env names actually lowered into this target's native config. */ worldTokenEnvNames?: string[]; @@ -128,6 +136,10 @@ export interface RuntimeAuthPreparationInput { export interface RuntimeAuthPreparationResult { coveredModelSecrets: string[]; + launchIdentity?: { + kind: "daimon"; + uid: number; + }; mountArgs: string[]; } diff --git a/src/target/AGENTS.md b/src/target/AGENTS.md index 221bcaa1..d03a6027 100644 --- a/src/target/AGENTS.md +++ b/src/target/AGENTS.md @@ -23,8 +23,13 @@ initialize state, acquire a mutation lock, or call a provider. - `snapshot_public_artifact` is a read-only public projection query, not a mutation or an evidence export. Its public contract declares one bounded - artifact below `/tmp/spawnfile-public/` and returns only canonical bytes plus - public correlation digests. The private provider resolves one exact recorded + direct child of `/tmp/spawnfile-public/` and returns only canonical bytes plus + public correlation digests. The world service provides that root as a + separately attested tmpfs mount, and one atomic `O_NOFOLLOW` open reads its + terminal child without a check-then-read race. Only the reader's exact empty + terminal-absence exit returns the strict versioned `not_present` result; + every link, replacement, request, authority, container, path-safety, or + provider failure remains permanent. The private provider resolves one exact recorded world-service handle and may copy only that declared path; it never lists, searches, reads logs, reads evidence volumes, publishes a port, or exposes a provider identity. @@ -78,8 +83,11 @@ destination paths, Docker identities, provider output, or evidence bytes through this boundary, and it is not barrel-exported. Helper verification is contract-bound and image-based: the image projection must include exact - helper contract label, immutable `RepoDigest`, exact expected immutable entrypoint, - empty `Cmd`, and non-root `User` before any container create/inspect/replay. + helper contract label, either an immutable registry reference or a Spawnfile-attested + local image config digest, exact expected immutable entrypoint, + empty `Cmd`, non-root `User`, and only the fixed nonsecret + `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` + environment entry before any container create/inspect/replay. Helper-container enforcement is fixed and isolated: `network none`, read-only root, no restart/logging/ports, bounded CPU/memory/pids, deterministic `volume-nocopy` mount, and explicit empty user/group/device/network-related diff --git a/src/target/dockerArtifacts.test.ts b/src/target/dockerArtifacts.test.ts index 9cce0442..4ea62206 100644 --- a/src/target/dockerArtifacts.test.ts +++ b/src/target/dockerArtifacts.test.ts @@ -163,6 +163,59 @@ describe("immutable Docker artifact resolution", () => { } }); + it("waits for an exact bounded pending prefix before joining its immutable binding", async () => { + const state: State = { calls: [] }; const setupValue = await setup(state); + const binding = { artifactManifestDigest: manifest, imageDigest, imageReference, operationHandle: parseOpaqueTargetHandle("opaque_aaaaaaaaaaaaaaaa"), requestDigest: `sha256:${"d".repeat(64)}`, resultHandle: parseOpaqueTargetHandle("opaque_bbbbbbbbbbbbbbbb"), selectedTargetHandle: setupValue.selected.handle }; + const interrupted = await initializeDockerArtifactIdentityStore(setupValue.identityRoot, { + beforePublish: async () => { throw new Error("crash"); }, + }); + await expect(interrupted.bind(binding)).rejects.toThrow("Docker artifact resolution failed"); + const pending = path.join(setupValue.identityRoot, (await readdir(setupValue.identityRoot)) + .find((item) => item.endsWith(".pending"))!); + const complete = await readFile(pending, "utf8"); + await writeFile(pending, complete.slice(0, 1), { mode: 0o600 }); + const originalSetImmediate = globalThis.setImmediate; + let turns = 0; + globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { + turns += 1; + if (turns === 20) void writeFile(pending, complete, { mode: 0o600 }).then(() => callback()); + else originalSetImmediate(callback); + return {} as NodeJS.Immediate; + }) as typeof setImmediate; + try { + await expect((await initializeDockerArtifactIdentityStore(setupValue.identityRoot)).bind(binding)) + .resolves.toBeUndefined(); + } finally { globalThis.setImmediate = originalSetImmediate; } + expect(turns).toBeGreaterThanOrEqual(20); + await expect((await initializeDockerArtifactIdentityStore(setupValue.identityRoot)) + .resolveOperation(binding.operationHandle, binding.requestDigest)).resolves.toEqual(binding); + }); + + it("reconciles in-flight pending records only after their exact state settles", async () => { + const state: State = { calls: [] }; const setupValue = await setup(state); + const binding = { artifactManifestDigest: manifest, imageDigest, imageReference, operationHandle: parseOpaqueTargetHandle("opaque_aaaaaaaaaaaaaaaa"), requestDigest: `sha256:${"d".repeat(64)}`, resultHandle: parseOpaqueTargetHandle("opaque_bbbbbbbbbbbbbbbb"), selectedTargetHandle: setupValue.selected.handle }; + const store = await initializeDockerArtifactIdentityStore(setupValue.identityRoot); await store.bind(binding); + const final = path.join(setupValue.identityRoot, (await readdir(setupValue.identityRoot)) + .find((item) => item.endsWith(".identity.json"))!); + const pending = path.join(setupValue.identityRoot, `.${path.basename(final)}.pending`); + const exact = await readFile(final, "utf8"); + const afterTwentyTurns = async (operation: () => Promise, repair: () => Promise) => { + const original = globalThis.setImmediate; let turns = 0; + globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { + turns += 1; + if (turns === 20) void repair().then(() => callback()); else original(callback); + return {} as NodeJS.Immediate; + }) as typeof setImmediate; + try { return await operation(); } finally { globalThis.setImmediate = original; expect(turns).toBeGreaterThanOrEqual(20); } + }; + await writeFile(pending, exact.slice(0, 1), { mode: 0o600 }); + await expect(afterTwentyTurns(() => store.resolveOperation(binding.operationHandle, binding.requestDigest), + async () => writeFile(pending, exact, { mode: 0o600 }))).resolves.toEqual(binding); + await writeFile(pending, exact, { mode: 0o600 }); const copy = `${pending}.copy`; await link(pending, copy); + await expect(afterTwentyTurns(() => store.resolveOperation(binding.operationHandle, binding.requestDigest), + async () => rm(copy))).resolves.toEqual(binding); + }); + it("joins when another independent store links the final after the first binder proved pending", async () => { const state: State = { calls: [] }; const setupValue = await setup(state); const binding = { artifactManifestDigest: manifest, imageDigest, imageReference, operationHandle: parseOpaqueTargetHandle("opaque_aaaaaaaaaaaaaaaa"), requestDigest: `sha256:${"d".repeat(64)}`, resultHandle: parseOpaqueTargetHandle("opaque_bbbbbbbbbbbbbbbb"), selectedTargetHandle: setupValue.selected.handle }; diff --git a/src/target/dockerArtifactsProvider.ts b/src/target/dockerArtifactsProvider.ts index 4a721c2f..d9450c64 100644 --- a/src/target/dockerArtifactsProvider.ts +++ b/src/target/dockerArtifactsProvider.ts @@ -27,6 +27,7 @@ const MAX_OUTPUT_BYTES = 32_768; const MAX_REPOSITORY_BYTES = 255; const MAX_REFERENCE_BYTES = MAX_REPOSITORY_BYTES + 72; const MAX_IDENTITY_BYTES = 262_144; +const IDENTITY_RETRY_ATTEMPTS = 64; const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; const PORT_PATTERN = /^[1-9][0-9]{0,4}$/u; const NAME_COMPONENT_PATTERN = /^[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*$/u; @@ -259,11 +260,18 @@ const openSecureIdentity = async (filePath: string): Promise undefined); } }; const openIdentity = async (filePath: string, links: readonly number[]): Promise => { - const value = await openSecureIdentity(filePath); if (value !== null && !links.includes(value.nlink)) return fail(); return value; + const value = await openSecureIdentity(filePath); + if (value === null || value.nlink === 0) return null; + if (!links.includes(value.nlink)) return fail(); + return value; }; const syncDirectory = async (directory: string): Promise => { const handle = await open(directory, constants.O_RDONLY); try { await handle.sync(); } finally { await handle.close(); } }; +const retryTurn = (attempt: number): Promise => new Promise((resolve) => { + if ((attempt + 1) % 8 === 0) setTimeout(resolve, 1); + else setImmediate(resolve); +}); /* One canonical immutable record exists for one exact operation/request pair. */ const keyPath = (root: string, operationHandle: string, requestDigest: string): string => path.join(root, `${digest("identity-operation", `${operationHandle}\0${requestDigest}`)}.identity.json`); @@ -278,17 +286,22 @@ const sameInode = (left: IdentityFile, right: IdentityFile): boolean => left.dev * what makes unlink-after-same-inode-proof safe here. */ const reconcilePublishedIdentity = async (root: string, file: string, expected: string): Promise => { - for (let attempt = 0; attempt < 16; attempt += 1) { + for (let attempt = 0; attempt < IDENTITY_RETRY_ATTEMPTS; attempt += 1) { const final = await openIdentity(file, [1, 2]); if (!final || final.bytes !== expected) return fail(); const pending = await openIdentity(pendingPath(file), [1, 2]); if (final.nlink === 1) { if (pending === null) return; - if (pending.bytes !== expected) return fail(); - if (pending.nlink !== 1) continue; + if (pending.bytes !== expected) { + if (pending.nlink === 1 && expected.startsWith(pending.bytes)) { + await retryTurn(attempt); continue; + } + return fail(); + } + if (pending.nlink !== 1) { await retryTurn(attempt); continue; } } else { /* A concurrent unlink can make a just-read two-link final become one-link. */ - if (pending === null || pending.nlink !== 2) continue; + if (pending === null || pending.nlink !== 2) { await retryTurn(attempt); continue; } if (!sameInode(final, pending) || pending.bytes !== expected) return fail(); } /* Safe only under the trusted-root boundary documented above. */ @@ -297,6 +310,7 @@ const reconcilePublishedIdentity = async (root: string, file: string, expected: const repaired = await openIdentity(file, [1]); if (!repaired || repaired.bytes !== expected) return fail(); if (await openIdentity(pendingPath(file), [1]) === null) return; + await retryTurn(attempt); } return fail(); }; @@ -312,7 +326,7 @@ const exactPublishedIdentity = async (root: string, file: string, content: strin }; const publishIdentity = async (root: string, file: string, content: string, options?: DockerArtifactIdentityStoreOptions): Promise => { const pending = pendingPath(file); - for (let attempt = 0; attempt < 16; attempt += 1) { + for (let attempt = 0; attempt < IDENTITY_RETRY_ATTEMPTS; attempt += 1) { if (await exactPublishedIdentity(root, file, content)) return; let created = false; let handle; try { @@ -327,12 +341,17 @@ const publishIdentity = async (root: string, file: string, content: string, opti const pendingRecord = await openIdentity(pending, [1, 2]); if (pendingRecord === null) { if (await exactPublishedIdentity(root, file, content)) return; - continue; + await retryTurn(attempt); continue; + } + if (pendingRecord.bytes !== content) { + if (pendingRecord.nlink === 1 && content.startsWith(pendingRecord.bytes)) { + await retryTurn(attempt); continue; + } + return fail(); } - if (pendingRecord.bytes !== content) return fail(); if (pendingRecord.nlink === 2) { if (await exactPublishedIdentity(root, file, content)) return; - continue; + await retryTurn(attempt); continue; } if (created) await options?.beforeLink?.(); try { @@ -344,6 +363,7 @@ const publishIdentity = async (root: string, file: string, content: string, opti } /* EEXIST may mean another exact binder linked first or a final vanished; retry only after an exact proof. */ if (await exactPublishedIdentity(root, file, content)) return; + await retryTurn(attempt); } return fail(); }; diff --git a/src/target/dockerArtifactsProviderRace.test.ts b/src/target/dockerArtifactsProviderRace.test.ts new file mode 100644 index 00000000..6cc44471 --- /dev/null +++ b/src/target/dockerArtifactsProviderRace.test.ts @@ -0,0 +1,63 @@ +import { link, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { parseOpaqueTargetHandle } from "./contracts.js"; +import { initializeDockerArtifactIdentityStore } from "./dockerArtifactsProvider.js"; + +const roots: string[] = []; +const digest = (character: string): string => `sha256:${character.repeat(64)}`; +const binding = { + artifactManifestDigest: digest("a"), imageDigest: digest("b"), + imageReference: `registry.example/world@${digest("b")}`, + operationHandle: parseOpaqueTargetHandle("opaque_aaaaaaaaaaaaaaaa"), + requestDigest: digest("c"), resultHandle: parseOpaqueTargetHandle("opaque_dddddddddddddddd"), + selectedTargetHandle: parseOpaqueTargetHandle("opaque_eeeeeeeeeeeeeeee"), +}; + +const root = async (): Promise => { + const value = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-artifact-race-"))); + roots.push(value); + return path.join(value, "identities"); +}; +const pendingFromInterruptedBind = async (identityRoot: string): Promise => { + const interrupted = await initializeDockerArtifactIdentityStore(identityRoot, { + beforePublish: async () => { throw new Error("crash"); }, + }); + await expect(interrupted.bind(binding)).rejects.toThrow("Docker artifact resolution failed"); + const entry = (await readdir(identityRoot)).find((name) => name.endsWith(".pending")); + if (!entry) throw new Error("expected pending identity"); + return path.join(identityRoot, entry); +}; + +afterEach(async () => Promise.all(roots.splice(0).map((value) => rm(value, { force: true, recursive: true })))); + +describe("Docker artifact identity publication races", () => { + it("fails closed for a non-prefix pending identity", async () => { + const identityRoot = await root(); const pending = await pendingFromInterruptedBind(identityRoot); + await writeFile(pending, "not-a-canonical-prefix", { mode: 0o600 }); + await expect((await initializeDockerArtifactIdentityStore(identityRoot)).bind(binding)) + .rejects.toThrow("Docker artifact resolution failed"); + }); + + it("does not accept a pending hardlink pair without its exact final", async () => { + const identityRoot = await root(); const pending = await pendingFromInterruptedBind(identityRoot); + await link(pending, `${pending}.copy`); + await expect((await initializeDockerArtifactIdentityStore(identityRoot)).bind(binding)) + .rejects.toThrow("Docker artifact resolution failed"); + }); + + it("rejects a final and pending pair that are not the same inode", async () => { + const identityRoot = await root(); const store = await initializeDockerArtifactIdentityStore(identityRoot); + await store.bind(binding); + const finalEntry = (await readdir(identityRoot)).find((name) => name.endsWith(".identity.json")); + if (!finalEntry) throw new Error("expected final identity"); + const final = path.join(identityRoot, finalEntry); const pending = path.join(identityRoot, `.${finalEntry}.pending`); + await writeFile(pending, await readFile(final, "utf8"), { mode: 0o600 }); + await Promise.all([link(final, `${final}.copy`), link(pending, `${pending}.copy`)]); + await expect(store.resolveOperation(binding.operationHandle, binding.requestDigest)) + .rejects.toThrow("Docker artifact resolution failed"); + }); +}); diff --git a/src/target/dockerBaseImage.test.ts b/src/target/dockerBaseImage.test.ts new file mode 100644 index 00000000..9ed98bfc --- /dev/null +++ b/src/target/dockerBaseImage.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { parseDockerBaseImageReference } from "./dockerBaseImage.js"; + +describe("Docker base-image reference", () => { + it.each(["node:22-bookworm-slim", `registry.example/node@sha256:${"a".repeat(64)}`])( + "accepts %s", (value) => expect(parseDockerBaseImageReference(value)).toBe(value) + ); + it.each([" sha", "sha ", "sha256:" + "a".repeat(64), "bad image", "node@sha256:short"])( + "rejects %s", (value) => expect(parseDockerBaseImageReference(value)).toBeNull() + ); +}); diff --git a/src/target/dockerBaseImage.ts b/src/target/dockerBaseImage.ts new file mode 100644 index 00000000..8cc61756 --- /dev/null +++ b/src/target/dockerBaseImage.ts @@ -0,0 +1,12 @@ +import { parseImageReference } from "../distribution/index.js"; + +const CONFIG_DIGEST = /^sha256:[a-f0-9]{64}$/u; +const MAX_REFERENCE_BYTES = 512; + +/** Strict portable image-reference grammar shared by target resolution and local helper setup. */ +export const parseDockerBaseImageReference = (raw: unknown): string | null => { + if (typeof raw !== "string" || raw !== raw.trim() || raw.includes("\0") + || Buffer.byteLength(raw, "utf8") > MAX_REFERENCE_BYTES || CONFIG_DIGEST.test(raw) + || parseImageReference(raw) === null) return null; + return raw; +}; diff --git a/src/target/dockerCommandExecutor.publicArtifact.test.ts b/src/target/dockerCommandExecutor.publicArtifact.test.ts new file mode 100644 index 00000000..89680e46 --- /dev/null +++ b/src/target/dockerCommandExecutor.publicArtifact.test.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from "node:events"; +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { + createDockerTargetExecutors, + DockerPublicArtifactNotPresentError, + PUBLIC_ARTIFACT_READER_PROGRAM, + type DockerCommandSpawn +} from "./dockerCommandExecutor.js"; + +const execFile = promisify(execFileCallback); + +class Child extends EventEmitter { + public readonly stdin = new PassThrough(); + public readonly stdout = new PassThrough(); + public readonly stderr = new PassThrough(); + public kill(): boolean { return true; } +} + +const executorFor = ( + code: number, + stderr: string, + stdout = "" +) => createDockerTargetExecutors({ + spawn: (() => { + const child = new Child(); + queueMicrotask(() => { + child.stdout.end(stdout); + child.stderr.end(stderr); + child.emit("close", code); + }); + return child; + }) as unknown as DockerCommandSpawn +}).publicArtifact; + +const exactProbe = [ + "--context", "local-dev", "container", "exec", "c".repeat(64), + "/usr/local/bin/node", "--input-type=module", "-e", PUBLIC_ARTIFACT_READER_PROGRAM, + "spawnfile-public-artifact-read", "/tmp/spawnfile-public/composed-terminal.json" +] as const; + +describe("Docker public-artifact command classification", () => { + it("types only the exact absent declared-path probe", async () => { + await expect(executorFor(42, "")("docker", exactProbe, { timeout: 100 })) + .rejects.toBeInstanceOf(DockerPublicArtifactNotPresentError); + }); + + it("keeps nearby provider and safety failures permanent", async () => { + const nearMisses = [ + { args: exactProbe, code: 2, stderr: "", stdout: "" }, + { args: exactProbe, code: 42, stderr: "permission denied", stdout: "" }, + { args: exactProbe, code: 42, stderr: "", stdout: "unexpected" }, + { args: [...exactProbe.slice(0, 5), "/bin/cat", ...exactProbe.slice(6)], code: 42, stderr: "", stdout: "" }, + { args: [...exactProbe.slice(0, -1), "/tmp/spawnfile-public/../private"], code: 42, stderr: "", stdout: "" }, + { args: [...exactProbe.slice(0, 4), "short-id", ...exactProbe.slice(5)], code: 42, stderr: "", stdout: "" } + ]; + for (const value of nearMisses) { + let error: unknown; + try { + await executorFor(value.code, value.stderr, value.stdout)( + "docker", value.args, { timeout: 100 } + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(DockerPublicArtifactNotPresentError); + expect((error as Error).message).toBe("Docker command failed"); + } + }); +}); + +describe("terminal public artifact reader", () => { + const runReader = async (file: string) => { + try { + const result = await execFile(process.execPath, ["--input-type=module", "-e", PUBLIC_ARTIFACT_READER_PROGRAM, file]); + return { ...result, code: 0 }; + } catch (error) { + const failure = error as { code?: number; stderr?: string; stdout?: string }; + return { code: failure.code, stderr: failure.stderr, stdout: failure.stdout }; + } + }; + + it("maps only the atomic open's absence to terminal absence", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-artifact-reader-")); + const file = path.join(directory, "terminal.json"); + try { + expect((await runReader(file)).code).toBe(42); + await writeFile(file, "created", "utf8"); + expect((await runReader(file)).stdout).toBe("created"); + await unlink(file); + // This create/remove sequence represents a path that vanished before + // its one permitted open; it remains the same typed absence outcome. + expect((await runReader(file)).code).toBe(42); + await symlink("/etc/passwd", file); + expect((await runReader(file)).code).toBe(43); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/target/dockerCommandExecutor.ts b/src/target/dockerCommandExecutor.ts index 79e8ce00..500c519d 100644 --- a/src/target/dockerCommandExecutor.ts +++ b/src/target/dockerCommandExecutor.ts @@ -19,7 +19,10 @@ import { type DockerWorldServiceExecutor } from "./dockerWorldServiceProvider.js"; import type { DockerEvidenceExportExecutor } from "./evidenceExportProvider.js"; -import { MAX_TARGET_PUBLIC_ARTIFACT_BYTES } from "./publicArtifactSnapshot.js"; +import { + MAX_TARGET_PUBLIC_ARTIFACT_BYTES, + isTargetPublicArtifactPath +} from "./publicArtifactSnapshot.js"; import { DOCKER_COMMAND_ERROR, DockerCommandFailure, @@ -28,6 +31,36 @@ import { } from "./dockerCommandExecutorCore.js"; type AdapterKind = "artifact" | "attachment" | "resource" | "secret" | "world"; + +const PUBLIC_ARTIFACT_NOT_PRESENT_EXIT = 42; +// The public directory is a separately mounted tmpfs and paths are restricted +// to direct children. Open the leaf exactly once with O_NOFOLLOW: no path +// preflight is permitted because it would turn a replacement into a TOCTOU +// disclosure. Only ENOENT from that open denotes the terminal absence state. +export const PUBLIC_ARTIFACT_READER_PROGRAM = [ + "import fs from 'node:fs';", + "const path = process.argv[1];", + "let fd;", + "try { fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); }", + "catch (error) { process.exitCode = error?.code === 'ENOENT' ? 42 : 43; }", + "if (fd !== undefined) {", + " try {", + " if (!fs.fstatSync(fd).isFile()) process.exitCode = 43;", + " else { const chunks = []; let bytes; do { const chunk = Buffer.allocUnsafe(65536); bytes = fs.readSync(fd, chunk); if (bytes) chunks.push(chunk.subarray(0, bytes)); } while (bytes); process.stdout.write(Buffer.concat(chunks)); }", + " } catch { process.exitCode = 43; } finally { fs.closeSync(fd); }", + "}" +].join(" "); + +/** Private control signal emitted only for an exact missing public path probe. */ +export class DockerPublicArtifactNotPresentError extends Error { + public readonly kind = "not_present" as const; + + public constructor() { + super("Target public artifact is not present"); + this.name = "DockerPublicArtifactNotPresentError"; + } +} + const role = (args: readonly string[]): readonly string[] => { if (args[0] === "--context") { return args.length >= 3 && /^[a-z][a-z0-9_-]{0,63}$/u.test(args[1]!) ? args.slice(2) : []; @@ -40,6 +73,24 @@ const role = (args: readonly string[]): readonly string[] => { } return args; }; +const exactPublicArtifactAbsence = ( + args: readonly string[], + error: unknown +): DockerPublicArtifactNotPresentError | undefined => { + if (!(error instanceof DockerCommandFailure) + || error.code !== PUBLIC_ARTIFACT_NOT_PRESENT_EXIT || error.stderr !== "" + || error.stdoutBytes !== 0) return undefined; + const command = role(args); + const path = command[8]; + if (command.length !== 9 + || command[0] !== "container" || command[1] !== "exec" + || !/^[a-f0-9]{64}$/u.test(command[2]!) + || command[3] !== "/usr/local/bin/node" || command[4] !== "--input-type=module" + || command[5] !== "-e" || command[6] !== PUBLIC_ARTIFACT_READER_PROGRAM + || command[7] !== "spawnfile-public-artifact-read" + || !isTargetPublicArtifactPath(path)) return undefined; + return new DockerPublicArtifactNotPresentError(); +}; const exactMissingImageReference = (requested: string | undefined, reported: string): boolean => { if (requested === undefined || reported === requested) return reported === requested; const lastSlash = requested.lastIndexOf("/"); @@ -136,6 +187,16 @@ export interface DockerTargetExecutors { readonly secret: DockerSecretExecutor; readonly world: DockerWorldServiceExecutor; } + +export const createPublicArtifactReadCommand = (input: { + readonly containerId: string; + readonly context: string; + readonly path: string; +}): string[] => [ + "--context", input.context, "container", "exec", input.containerId, + "/usr/local/bin/node", "--input-type=module", "-e", PUBLIC_ARTIFACT_READER_PROGRAM, + "spawnfile-public-artifact-read", input.path +]; export interface CreateDockerTargetExecutorsOptions { readonly dockerCommand?: string; readonly spawn?: DockerCommandSpawn; @@ -214,7 +275,11 @@ export const createDockerTargetExecutors = ( stdoutCap: MAX_TARGET_PUBLIC_ARTIFACT_BYTES }); return { bytes: Uint8Array.from(result.stdout as Uint8Array) }; - } catch { throw new Error(DOCKER_COMMAND_ERROR); } + } catch (error) { + const absence = exactPublicArtifactAbsence(args, error); + if (absence) throw absence; + throw new Error(DOCKER_COMMAND_ERROR); + } }, resource: text("resource"), secret: text("secret"), diff --git a/src/target/dockerCommandExecutorCore.ts b/src/target/dockerCommandExecutorCore.ts index ad9bc8af..faf170bc 100644 --- a/src/target/dockerCommandExecutorCore.ts +++ b/src/target/dockerCommandExecutorCore.ts @@ -25,10 +25,12 @@ export type DockerCommandSpawn = ( export class DockerCommandFailure extends Error { public readonly code: number; public readonly stderr: string; - public constructor(code: number, stderr: string) { + public readonly stdoutBytes: number; + public constructor(code: number, stderr: string, stdoutBytes: number) { super(DOCKER_COMMAND_ERROR); this.code = code; this.stderr = stderr; + this.stdoutBytes = stdoutBytes; } } @@ -217,7 +219,11 @@ export const executeDockerCommandCore = ( return; } if (closeCode !== 0) { - settle(new DockerCommandFailure(closeCode, stderr)); + settle(new DockerCommandFailure( + closeCode, + stderr, + typeof stdout === "string" ? Buffer.byteLength(stdout, "utf8") : stdout.byteLength + )); return; } settle(undefined, { stderr, stdout }); diff --git a/src/target/dockerPublicArtifactSnapshot.test.ts b/src/target/dockerPublicArtifactSnapshot.test.ts index 58f25530..141d1fb0 100644 --- a/src/target/dockerPublicArtifactSnapshot.test.ts +++ b/src/target/dockerPublicArtifactSnapshot.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { parseOpaqueTargetHandle } from "./contracts.js"; import { createDockerArtifactSpec } from "./dockerArtifactsProvider.js"; -import type { DockerTargetExecutors } from "./dockerCommandExecutor.js"; +import { + DockerPublicArtifactNotPresentError, + PUBLIC_ARTIFACT_READER_PROGRAM, + type DockerTargetExecutors +} from "./dockerCommandExecutor.js"; import { createDockerResourceSpec } from "./dockerResourcesProvider.js"; import { createExistingDockerSecretSpec } from "./dockerSecretsProvider.js"; import { createWorldServiceAuthorization } from "./dockerWorldServiceAuthority.js"; @@ -22,6 +26,8 @@ import { type WorldServiceBinding } from "./dockerWorldServiceStore.js"; import { + createTargetPublicArtifactSnapshotRequestDigest, + parseTargetPublicArtifactSnapshot, parseTargetPublicArtifactSnapshotRequest } from "./publicArtifactSnapshot.js"; @@ -119,7 +125,10 @@ const inspection = (spec: DockerWorldServiceSpec): Record => ({ PidMode: "", PortBindingCount: 0, Privileged: false, PublishAllPorts: false, ReadonlyRootfs: true, RestartMaximumRetryCount: 0, RestartPolicyName: "no", SecurityOpt: ["no-new-privileges=true"], Status: "running", - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { + "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", + "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" + }, UTSMode: "", UsernsMode: "", VolumesFromCount: 0 }); @@ -163,34 +172,109 @@ describe("Docker public artifact snapshot adapter", () => { async (_file, args) => { contentCalls.push([...args]); return { bytes: Uint8Array.from(Buffer.from( - args[5] === "/usr/bin/readlink" - ? "/tmp/spawnfile-public/viewer-trace.json\n" - : "{\"tick\":12}" + args[10] === "/tmp/spawnfile-public/viewer-trace.json" + ? "{\"tick\":12}" + : "" )) }; }; - const snapshot = await createDockerPublicArtifactSnapshotReader({ - authorityStore: authority(binding), - context: "gpu-host", - contentExecutor, - executor, - timeoutMs: 30_000 - }).snapshot(requestFor(binding)); + const snapshot = parseTargetPublicArtifactSnapshot( + await createDockerPublicArtifactSnapshotReader({ + authorityStore: authority(binding), + context: "gpu-host", + contentExecutor, + executor, + timeoutMs: 30_000 + }).snapshot(requestFor(binding)) + ); expect(Buffer.from(snapshot.content_base64, "base64").toString("utf8")) .toBe("{\"tick\":12}"); expect(calls.filter((args) => args[3] === "inspect")).toHaveLength(2); expect(contentCalls).toEqual([ [ "--context", "gpu-host", "container", "exec", - containerId, "/usr/bin/readlink", "-e", - "/tmp/spawnfile-public/viewer-trace.json" - ], - [ - "--context", "gpu-host", "container", "exec", - containerId, "/bin/cat", "/tmp/spawnfile-public/viewer-trace.json" + containerId, "/usr/local/bin/node", "--input-type=module", "-e", PUBLIC_ARTIFACT_READER_PROGRAM, + "spawnfile-public-artifact-read", "/tmp/spawnfile-public/viewer-trace.json" ] ]); }); + it("returns a correlated not-present outcome only for the typed path probe", async () => { + const binding = fixtureBinding(); + const request = requestFor(binding); + const spec = worldServiceSpecForBinding(binding); + let inspections = 0; + const executor: DockerWorldServiceExecutor = async (_file, args) => { + if (args[3] === "inspect") { + inspections += 1; + return { stderr: "", stdout: JSON.stringify([inspection(spec)]) }; + } + throw new Error("unexpected provider command"); + }; + const contentExecutor: DockerTargetExecutors["publicArtifact"] = async () => { + throw new DockerPublicArtifactNotPresentError(); + }; + await expect(createDockerPublicArtifactSnapshotReader({ + authorityStore: authority(binding), + context: "gpu-host", + contentExecutor, + executor, + timeoutMs: 30_000 + }).snapshot(request)).resolves.toEqual({ + artifact_id: "viewer_trace", + request_digest: createTargetPublicArtifactSnapshotRequestDigest(request), + run_id: "run-public", + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1" + }); + expect(inspections).toBe(2); + }); + + it("keeps a world that stops across an absent probe as a permanent failure", async () => { + const binding = fixtureBinding(); + const spec = worldServiceSpecForBinding(binding); + let inspections = 0; + const executor: DockerWorldServiceExecutor = async (_file, args) => { + if (args[3] !== "inspect") throw new Error("unexpected provider command"); + inspections += 1; + return { + stderr: "", + stdout: JSON.stringify([{ + ...inspection(spec), + Status: inspections === 1 ? "running" : "exited" + }]) + }; + }; + await expect(createDockerPublicArtifactSnapshotReader({ + authorityStore: authority(binding), + context: "gpu-host", + contentExecutor: async () => { throw new DockerPublicArtifactNotPresentError(); }, + executor, + timeoutMs: 30_000 + }).snapshot(requestFor(binding))).rejects.toThrow( + "Target public artifact snapshot failed" + ); + }); + + it("keeps untyped read failures permanent", async () => { + const binding = fixtureBinding(); + const spec = worldServiceSpecForBinding(binding); + const executor: DockerWorldServiceExecutor = async (_file, args) => { + if (args[3] === "inspect") { + return { stderr: "", stdout: JSON.stringify([inspection(spec)]) }; + } + throw new Error("unexpected provider command"); + }; + await expect(createDockerPublicArtifactSnapshotReader({ + authorityStore: authority(binding), + context: "gpu-host", + contentExecutor: async () => { throw new Error("provider failed"); }, + executor, + timeoutMs: 30_000 + }).snapshot(requestFor(binding))).rejects.toThrow( + "Target public artifact snapshot failed" + ); + }); + it("fails closed on correlation drift and oversized copied content", async () => { const binding = fixtureBinding(); const request = requestFor(binding); @@ -219,9 +303,9 @@ describe("Docker public artifact snapshot adapter", () => { const oversized: DockerTargetExecutors["publicArtifact"] = async (_file, args) => ({ bytes: Uint8Array.from(Buffer.from( - args[5] === "/usr/bin/readlink" - ? `${request.artifact.path}\n` - : "x".repeat(request.artifact.max_bytes + 1) + args[10] === request.artifact.path + ? "x".repeat(request.artifact.max_bytes + 1) + : "" )) }); await expect(createDockerPublicArtifactSnapshotReader({ @@ -233,7 +317,7 @@ describe("Docker public artifact snapshot adapter", () => { }).snapshot(request)).rejects.toThrow("Target public artifact snapshot failed"); }); - it("rejects a declared public path that resolves through any symlink", async () => { + it("keeps a nofollow link or replacement race permanent", async () => { const binding = fixtureBinding(); const request = requestFor(binding); const spec = worldServiceSpecForBinding(binding); @@ -247,11 +331,7 @@ describe("Docker public artifact snapshot adapter", () => { const contentExecutor: DockerTargetExecutors["publicArtifact"] = async (_file, args) => { calls.push([...args]); - return { - bytes: Uint8Array.from(Buffer.from( - "/run/spawnfile-secrets/world-token\n" - )) - }; + throw new Error("atomic open rejected symlink"); }; await expect(createDockerPublicArtifactSnapshotReader({ authorityStore: authority(binding), @@ -261,6 +341,6 @@ describe("Docker public artifact snapshot adapter", () => { timeoutMs: 30_000 }).snapshot(request)).rejects.toThrow("Target public artifact snapshot failed"); expect(calls).toHaveLength(1); - expect(calls[0]?.[5]).toBe("/usr/bin/readlink"); + expect(calls[0]?.[10]).toBe(request.artifact.path); }); }); diff --git a/src/target/dockerPublicArtifactSnapshot.ts b/src/target/dockerPublicArtifactSnapshot.ts index d42c5767..ca9cafb5 100644 --- a/src/target/dockerPublicArtifactSnapshot.ts +++ b/src/target/dockerPublicArtifactSnapshot.ts @@ -1,10 +1,15 @@ import { SpawnfileError } from "../shared/index.js"; -import type { DockerTargetExecutors } from "./dockerCommandExecutor.js"; +import { + createPublicArtifactReadCommand, + DockerPublicArtifactNotPresentError, + type DockerTargetExecutors +} from "./dockerCommandExecutor.js"; import { createTargetPublicArtifactSnapshot, + createTargetPublicArtifactSnapshotNotPresent, parseTargetPublicArtifactSnapshotRequest, - type TargetPublicArtifactSnapshot, - type TargetPublicArtifactSnapshotRequest + type TargetPublicArtifactSnapshotRequest, + type TargetPublicArtifactSnapshotResult } from "./publicArtifactSnapshot.js"; import { type DockerWorldServiceExecutor @@ -29,7 +34,7 @@ export interface DockerPublicArtifactSnapshotOptions { } export interface PublicArtifactSnapshotReader { - snapshot(raw: unknown): Promise; + snapshot(raw: unknown): Promise; } const CONTEXT_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/u; @@ -67,42 +72,24 @@ const readPublicArtifact = async ( containerId: string, request: TargetPublicArtifactSnapshotRequest, options: DockerPublicArtifactSnapshotOptions -): Promise => { +): Promise => { + let result: { readonly bytes: Uint8Array }; try { - const resolved = await options.contentExecutor("docker", [ - "--context", options.context, - "container", "exec", - containerId, - "/usr/bin/readlink", - "-e", - request.artifact.path - ], { - signal: options.signal, - timeout: options.timeoutMs - }); - if (!resolved || !(resolved.bytes instanceof Uint8Array)) return fail(); - const resolvedPath = new TextDecoder("utf-8", { fatal: true }) - .decode(resolved.bytes); - // Reject the final file and every parent alias. The world may publish only - // the exact regular path that its descriptor declared, never a symlink - // into private evidence, credentials, or another runtime surface. - if (resolvedPath !== `${request.artifact.path}\n`) return fail(); - const result = await options.contentExecutor("docker", [ - "--context", options.context, - "container", "exec", + result = await options.contentExecutor("docker", createPublicArtifactReadCommand({ containerId, - "/bin/cat", - request.artifact.path - ], { + context: options.context, + path: request.artifact.path + }), { signal: options.signal, timeout: options.timeoutMs }); - if (!result || !(result.bytes instanceof Uint8Array) - || result.bytes.byteLength > request.artifact.max_bytes) return fail(); - return Uint8Array.from(result.bytes); - } catch { + } catch (error) { + if (error instanceof DockerPublicArtifactNotPresentError) return null; return fail(); } + if (!result || !(result.bytes instanceof Uint8Array) + || result.bytes.byteLength > request.artifact.max_bytes) return fail(); + return Uint8Array.from(result.bytes); }; class DockerPublicArtifactSnapshotReader implements PublicArtifactSnapshotReader { @@ -112,7 +99,7 @@ class DockerPublicArtifactSnapshotReader implements PublicArtifactSnapshotReader this.#options = validOptions(options); } - public async snapshot(raw: unknown): Promise { + public async snapshot(raw: unknown): Promise { let request: TargetPublicArtifactSnapshotRequest; try { request = parseTargetPublicArtifactSnapshotRequest(raw); } catch { return fail(); } @@ -138,7 +125,9 @@ class DockerPublicArtifactSnapshotReader implements PublicArtifactSnapshotReader ).catch(fail); if (!after || after.containerId !== before.containerId || after.status !== "running") return fail(); - return createTargetPublicArtifactSnapshot({ content, request }); + return content === null + ? createTargetPublicArtifactSnapshotNotPresent(request) + : createTargetPublicArtifactSnapshot({ content, request }); } } diff --git a/src/target/dockerTarget.ts b/src/target/dockerTarget.ts index fc5938b0..4f93e2a9 100644 --- a/src/target/dockerTarget.ts +++ b/src/target/dockerTarget.ts @@ -11,7 +11,7 @@ export type DockerDeploymentTarget = export type DockerTargetExecFile = ( file: string, args: string[], - options: { signal?: AbortSignal; timeout: number } + options: { signal?: AbortSignal; stdin?: Uint8Array; timeout: number } ) => Promise<{ stderr: string; stdout: string }>; export interface ResolveDockerDeploymentTargetOptions { diff --git a/src/target/dockerTargetBinding.ts b/src/target/dockerTargetBinding.ts index 2e435b75..6cdd7a2a 100644 --- a/src/target/dockerTargetBinding.ts +++ b/src/target/dockerTargetBinding.ts @@ -1,13 +1,12 @@ -import { execFile as execFileCallback } from "node:child_process"; import { createHash } from "node:crypto"; -import { promisify } from "node:util"; import { SpawnfileError } from "../shared/index.js"; import { SELECTED_TARGET_VERSION, parseOpaqueTargetHandle, parseSelectedTargetReceipt, type SelectedTargetReceipt } from "./contracts.js"; import { createEndpointFingerprint } from "./dockerEndpointFingerprint.js"; import type { DockerTargetExecFile, ResolveDockerDeploymentTargetOptions, SelectTargetOptions } from "./dockerTarget.js"; +import { defaultDockerTargetExecFile } from "./dockerTargetExecFile.js"; -export const defaultDockerTargetExecFile = promisify(execFileCallback); +export { defaultDockerTargetExecFile } from "./dockerTargetExecFile.js"; const TARGET_SELECTION_ERROR = "Target selection failed"; const DOCKER_CONTEXT_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/u; const MAX_RAW_EXECUTOR_STDOUT_BYTES = 4_096; diff --git a/src/target/dockerTargetExecFile.test.ts b/src/target/dockerTargetExecFile.test.ts new file mode 100644 index 00000000..c61244c0 --- /dev/null +++ b/src/target/dockerTargetExecFile.test.ts @@ -0,0 +1,82 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createBoundedDockerTargetExecFile } from "./dockerTargetExecFile.js"; + +const roots: string[] = []; +const alive = (pid: number): boolean => { + try { process.kill(pid, 0); return true; } + catch { return false; } +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("bounded Docker target executable", () => { + it("delivers the exact build context on stdin", async () => { + const execute = createBoundedDockerTargetExecFile(); + const result = await execute(process.execPath, [ + "--input-type=module", "-e", + "const chunks=[];for await(const chunk of process.stdin)chunks.push(chunk);process.stdout.write(Buffer.concat(chunks).toString('hex'))", + ], { stdin: Uint8Array.from([0, 1, 2, 255]), timeout: 2_000 }); + expect(result.stdout).toBe("000102ff"); + }); + + it.skipIf(process.platform === "win32").each(["abort", "timeout"] as const)( + "bounds %s and kills a descendant retaining inherited stdio", + async (mode) => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-target-exec-")); + roots.push(root); + const pidFile = path.join(root, "descendant.pid"); + const program = [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + "const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:['ignore',1,2]});", + `writeFileSync(${JSON.stringify(pidFile)},String(child.pid));`, + "setInterval(()=>{},1000);", + ].join(""); + const controller = new AbortController(); + const started = Date.now(); + const execution = createBoundedDockerTargetExecFile()(process.execPath, [ + "--input-type=module", "-e", program, + ], { signal: controller.signal, timeout: mode === "timeout" ? 100 : 2_000 }); + if (mode === "abort") setTimeout(() => controller.abort(), 100); + await expect(execution).rejects.toMatchObject({ kind: mode === "abort" ? "aborted" : "timeout" }); + expect(Date.now() - started).toBeLessThan(1_000); + const descendant = Number(await readFile(pidFile, "utf8")); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(alive(descendant)).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "reports unresolved cleanup when the process group cannot be signalled or proved absent", + async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-target-eperm-")); + roots.push(root); + const pidFile = path.join(root, "group.pid"); + const program = `import{writeFileSync}from'node:fs';writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`; + const realKill = process.kill.bind(process); + const denied = vi.spyOn(process, "kill").mockImplementation(((pid: number, signal?: number | NodeJS.Signals) => { + if (pid < 0) throw Object.assign(new Error("denied"), { code: "EPERM" }); + return realKill(pid, signal); + }) as typeof process.kill); + const started = Date.now(); + try { + await expect(createBoundedDockerTargetExecFile()(process.execPath, [ + "--input-type=module", "-e", program, + ], { timeout: 100 })).rejects.toMatchObject({ kind: "cleanup_failed" }); + expect(Date.now() - started).toBeGreaterThanOrEqual(300); + expect(Date.now() - started).toBeLessThan(1_000); + } finally { + denied.mockRestore(); + const group = Number(await readFile(pidFile, "utf8")); + try { realKill(-group, "SIGKILL"); } catch { /* test cleanup */ } + } + }, + ); +}); diff --git a/src/target/dockerTargetExecFile.ts b/src/target/dockerTargetExecFile.ts new file mode 100644 index 00000000..e7f9eace --- /dev/null +++ b/src/target/dockerTargetExecFile.ts @@ -0,0 +1,172 @@ +import { spawn } from "node:child_process"; + +import type { DockerTargetExecFile } from "./dockerTarget.js"; + +const ERROR = "Docker target command failed"; +const OUTPUT_CAP = 65_536; +const TERM_GRACE_MS = 50; +const KILL_GRACE_MS = 250; + +export type DockerTargetExecutionFailureKind = "aborted" | "cleanup_failed" | "failed" | "timeout"; + +export class DockerTargetExecutionError extends Error { + public readonly kind: DockerTargetExecutionFailureKind; + public constructor(kind: DockerTargetExecutionFailureKind) { + super(ERROR); + this.name = "DockerTargetExecutionError"; + this.kind = kind; + } +} + +export class DockerTargetCommandFailure extends DockerTargetExecutionError { + public readonly code: number; + public readonly stderr: string; + public readonly stdoutBytes: number; + public constructor(code: number, stderr: string, stdoutBytes: number) { + super("failed"); + this.name = "DockerTargetCommandFailure"; + this.code = code; + this.stderr = stderr; + this.stdoutBytes = stdoutBytes; + } +} + +const decode = (chunks: readonly Buffer[]): string => { + try { return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); } + catch { throw new DockerTargetExecutionError("failed"); } +}; + +/** + * Docker target probe/build transport with exact stdin and bounded tree teardown. + * POSIX children lead a private process group so timeout/abort cannot strand a + * buildx descendant that inherited the CLI's stdio handles. + */ +export const createBoundedDockerTargetExecFile = (): DockerTargetExecFile => + (file, args, options) => { + if (typeof file !== "string" || file.length < 1 || file.includes("\0") + || !Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0")) + || !options || !Number.isSafeInteger(options.timeout) || options.timeout < 1 + || options.timeout > 120_000 + || options.signal !== undefined && !(options.signal instanceof AbortSignal) + || options.stdin !== undefined && !(options.stdin instanceof Uint8Array)) { + return Promise.reject(new DockerTargetExecutionError("failed")); + } + if (options.signal?.aborted) { + return Promise.reject(new DockerTargetExecutionError("aborted")); + } + return new Promise((resolve, reject) => { + const grouped = process.platform !== "win32"; + let child; + try { + child = spawn(file, args, { + detached: grouped, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch { + reject(new DockerTargetExecutionError("failed")); + return; + } + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let terminal: DockerTargetExecutionError | undefined; + let settled = false; + let termTimer: NodeJS.Timeout | undefined; + let killTimer: NodeJS.Timeout | undefined; + + const groupAbsent = (): boolean => { + if (!grouped || !child.pid) return false; + try { process.kill(-child.pid, 0); return false; } + catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH"; } + }; + const signalTree = (signal: NodeJS.Signals): void => { + try { + if (grouped && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch { /* only a later ESRCH liveness probe proves quiescence */ } + }; + const destroyPipes = (): void => { + child.stdin.on("error", () => undefined); + child.stdout.on("error", () => undefined); + child.stderr.on("error", () => undefined); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + }; + const cleanup = (): void => { + clearTimeout(timeoutTimer); + if (termTimer) clearTimeout(termTimer); + if (killTimer) clearTimeout(killTimer); + options.signal?.removeEventListener("abort", onAbort); + }; + const finish = (error: DockerTargetExecutionError, unresolved = false): void => { + if (settled || !unresolved && !groupAbsent()) return; + settled = true; + cleanup(); + destroyPipes(); + reject(error); + }; + const terminate = (kind: DockerTargetExecutionFailureKind): void => { + if (terminal || settled) return; + terminal = new DockerTargetExecutionError(kind); + signalTree("SIGTERM"); + termTimer = setTimeout(() => { + signalTree("SIGKILL"); + destroyPipes(); + const started = Date.now(); + const poll = (): void => { + if (!terminal || settled) return; + if (groupAbsent()) { finish(terminal); return; } + signalTree("SIGKILL"); + if (Date.now() - started >= KILL_GRACE_MS) { + finish(new DockerTargetExecutionError("cleanup_failed"), true); + return; + } + killTimer = setTimeout(poll, 10); + }; + poll(); + }, TERM_GRACE_MS); + }; + const append = (target: Buffer[], chunk: unknown, output: "stderr" | "stdout"): void => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + if (output === "stdout") stdoutBytes += value.byteLength; + else stderrBytes += value.byteLength; + if (stdoutBytes > OUTPUT_CAP || stderrBytes > OUTPUT_CAP) { terminate("failed"); return; } + target.push(Buffer.from(value)); + }; + function onAbort(): void { terminate("aborted"); } + child.stdout.on("data", (chunk) => append(stdout, chunk, "stdout")); + child.stderr.on("data", (chunk) => append(stderr, chunk, "stderr")); + child.stdin.on("error", () => terminate("failed")); + child.on("error", () => terminate("failed")); + child.on("close", (code) => { + if (terminal || settled) return; + settled = true; + cleanup(); + try { + const stderrText = decode(stderr); + const stdoutText = decode(stdout); + if (code !== 0) { + reject(new DockerTargetCommandFailure( + code ?? -1, stderrText, Buffer.byteLength(stdoutText, "utf8"), + )); + return; + } + resolve({ stderr: stderrText, stdout: stdoutText }); + } + catch (error) { reject(error); } + }); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeout); + if (options.signal?.aborted) { terminate("aborted"); return; } + try { + const input = options.stdin === undefined ? undefined + : Buffer.from(options.stdin.buffer, options.stdin.byteOffset, options.stdin.byteLength); + child.stdin.end(input); + } catch { terminate("failed"); } + }); + }; + +export const defaultDockerTargetExecFile = createBoundedDockerTargetExecFile(); diff --git a/src/target/dockerWorldClock.test.ts b/src/target/dockerWorldClock.test.ts index 5ae91ad7..b1575d2d 100644 --- a/src/target/dockerWorldClock.test.ts +++ b/src/target/dockerWorldClock.test.ts @@ -58,7 +58,7 @@ const inspection = (spec: DockerWorldServiceSpec): Record => ({ NetworkAttachmentCount: 1, NetworkAttachmentId: "b".repeat(64), NetworkAttachmentName: after(spec.createArgs, "--network"), NetworkMode: after(spec.createArgs, "--network"), PidMode: "", PortBindingCount: 0, Privileged: false, PublishAllPorts: false, ReadonlyRootfs: true, RestartMaximumRetryCount: 0, RestartPolicyName: "no", - SecurityOpt: ["no-new-privileges=true"], Status: "running", Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + SecurityOpt: ["no-new-privileges=true"], Status: "running", Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, UTSMode: "", UsernsMode: "", VolumesFromCount: 0, }); const marker = parseWorldServiceActivation({ diff --git a/src/target/dockerWorldReadiness.test.ts b/src/target/dockerWorldReadiness.test.ts index 594fb5a4..6815e5e1 100644 --- a/src/target/dockerWorldReadiness.test.ts +++ b/src/target/dockerWorldReadiness.test.ts @@ -109,7 +109,7 @@ const inspection = (spec: DockerWorldServiceSpec): Record => ({ PidMode: "", PortBindingCount: 0, Privileged: false, PublishAllPorts: false, ReadonlyRootfs: true, RestartMaximumRetryCount: 0, RestartPolicyName: "no", SecurityOpt: ["no-new-privileges=true"], Status: "running", - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, UTSMode: "", UsernsMode: "", VolumesFromCount: 0 }); diff --git a/src/target/dockerWorldService.test.ts b/src/target/dockerWorldService.test.ts index d371d47a..ec127148 100644 --- a/src/target/dockerWorldService.test.ts +++ b/src/target/dockerWorldService.test.ts @@ -208,7 +208,7 @@ const containerProjection = ( Domainname: "", ExposedPortCount: 0, ExtraHostCount: 0, GroupAddCount: 0, Hostname: spec.containerName, Id: container.id, Image: spec.imageReference, IpcMode: "none", Labels: spec.receiptLabels, LinkCount: 0, LogType: "none", - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, Mounts: mountsFor(spec), Name: `/${spec.containerName}`, NetworkAttachmentCount: 1, NetworkAttachmentId: "b".repeat(64), NetworkAttachmentName: valueAfter(spec.createArgs, "--network"), NetworkAliases: spec.networkAlias ? [spec.networkAlias] : null, NetworkMode: valueAfter(spec.createArgs, "--network"), PidMode: "", PortBindingCount: 0, diff --git a/src/target/dockerWorldServiceCleanup.test.ts b/src/target/dockerWorldServiceCleanup.test.ts index 65419da4..67cc07db 100644 --- a/src/target/dockerWorldServiceCleanup.test.ts +++ b/src/target/dockerWorldServiceCleanup.test.ts @@ -76,7 +76,7 @@ const projection = ( CgroupnsMode: "private", DeviceCount: 0, DeviceRequestCount: 0, DnsCount: 0, Domainname: "", ExposedPortCount: 0, ExtraHostCount: 0, GroupAddCount: 0, Hostname: spec.containerName, Id: containerId, Image: spec.imageReference, IpcMode: "none", - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, Labels: drift ? { ...spec.receiptLabels, extra: "foreign" } : spec.receiptLabels, LinkCount: 0, LogType: "none", Mounts: spec.createArgs.filter((value, index, values) => values[index - 1] === "--mount") diff --git a/src/target/dockerWorldServiceProvider.test.ts b/src/target/dockerWorldServiceProvider.test.ts index 153cd85d..ae4fb069 100644 --- a/src/target/dockerWorldServiceProvider.test.ts +++ b/src/target/dockerWorldServiceProvider.test.ts @@ -85,7 +85,10 @@ const inspection = (spec: DockerWorldServiceSpec): Record => ({ GroupAddCount: 0, PidMode: "", IpcMode: "none", - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { + "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", + "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" + }, UTSMode: "", UsernsMode: "", CgroupnsMode: "private", @@ -123,6 +126,7 @@ describe("Docker world-service provider", () => { "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true", "--ipc", "none", "--cgroupns", "private", "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=1m,mode=1777", + "--tmpfs", "/tmp/spawnfile-public:rw,noexec,nosuid,nodev,size=1m,mode=1777", ...Object.entries(spec.receiptLabels).flatMap(([key, value]) => ["--label", `${key}=${value}`]), "--mount", `type=volume,src=${evidence.name},dst=/run/world/evidence,volume-nocopy`, "--mount", `type=volume,src=${secrets.volumeName},dst=/run/spawnfile-secrets,readonly,volume-nocopy`, @@ -199,8 +203,14 @@ describe("Docker world-service provider", () => { ["host PID", (value) => { value.PidMode = "host"; }], ["shared IPC", (value) => { value.IpcMode = "host"; }], ["missing runtime tmpfs", (value) => { value.Tmpfs = {}; }], + ["missing public-artifact tmpfs", (value) => { + value.Tmpfs = { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }; + }], ["writable executable tmpfs", (value) => { - value.Tmpfs = { "/tmp": "rw,nosuid,nodev,size=1m,mode=1777" }; + value.Tmpfs = { + "/tmp": "rw,nosuid,nodev,size=1m,mode=1777", + "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" + }; }], ["host UTS", (value) => { value.UTSMode = "host"; }], ["host userns", (value) => { value.UsernsMode = "host"; }], diff --git a/src/target/dockerWorldServiceProvider.ts b/src/target/dockerWorldServiceProvider.ts index ce6d7a9c..158da4eb 100644 --- a/src/target/dockerWorldServiceProvider.ts +++ b/src/target/dockerWorldServiceProvider.ts @@ -16,6 +16,16 @@ export const WORLD_RUNTIME_TMPFS = Object.freeze({ path: "/tmp", options: "rw,noexec,nosuid,nodev,size=1m,mode=1777" }); +/* + * A distinct mount point is a confinement boundary, not merely a convenient + * directory. The public-artifact reader opens one direct child with + * O_NOFOLLOW; this mount prevents that child from being hard-linked or + * replaced with a path from the world root, secrets, or evidence mounts. + */ +export const WORLD_PUBLIC_ARTIFACT_TMPFS = Object.freeze({ + path: "/tmp/spawnfile-public", + options: "rw,noexec,nosuid,nodev,size=1m,mode=1777" +}); const MAX_OUTPUT_BYTES = 32_768; const MAX_NAME_LENGTH = 63; @@ -249,6 +259,7 @@ export const createDockerWorldServiceSpec = (input: { "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true", "--ipc", "none", "--cgroupns", "private", "--tmpfs", `${WORLD_RUNTIME_TMPFS.path}:${WORLD_RUNTIME_TMPFS.options}`, + "--tmpfs", `${WORLD_PUBLIC_ARTIFACT_TMPFS.path}:${WORLD_PUBLIC_ARTIFACT_TMPFS.options}`, ...labelArgs, "--mount", `type=volume,src=${evidence.name},dst=${evidenceMountPath},volume-nocopy`, "--mount", `type=volume,src=${secrets.name},dst=${WORLD_SECRETS_PATH},readonly,volume-nocopy`, @@ -369,8 +380,9 @@ export const parseExpectedDockerWorldService = ( && value.PidMode === "" && value.IpcMode === "none" && value.UTSMode === "" && value.UsernsMode === "" && value.CgroupnsMode === "private" && exactRecord(value.Tmpfs) - && exactKeys(value.Tmpfs, [WORLD_RUNTIME_TMPFS.path]) + && exactKeys(value.Tmpfs, [WORLD_RUNTIME_TMPFS.path, WORLD_PUBLIC_ARTIFACT_TMPFS.path]) && value.Tmpfs[WORLD_RUNTIME_TMPFS.path] === WORLD_RUNTIME_TMPFS.options + && value.Tmpfs[WORLD_PUBLIC_ARTIFACT_TMPFS.path] === WORLD_PUBLIC_ARTIFACT_TMPFS.options && value.ReadonlyRootfs === true && JSON.stringify(value.SecurityOpt) === JSON.stringify(["no-new-privileges=true"]) && value.LogType === "none" && value.RestartPolicyName === "no" diff --git a/src/target/dockerWorldServiceRecovery.test.ts b/src/target/dockerWorldServiceRecovery.test.ts index a91a34e4..14a7b299 100644 --- a/src/target/dockerWorldServiceRecovery.test.ts +++ b/src/target/dockerWorldServiceRecovery.test.ts @@ -162,7 +162,7 @@ const projection = (spec: DockerWorldServiceSpec, current: NonNullable, imageL Image: `docker.io/example/exporter@sha256:${"b".repeat(64)}`, Entrypoint: EVIDENCE_EXPORT_HELPER_ENTRYPOINT, Cmd: EVIDENCE_EXPORT_HELPER_CMD, - Env: null, + Env: EVIDENCE_EXPORT_HELPER_ENV, ExposedPorts: null, Healthcheck: null, Labels: { ...imageLabels, ...runtimeLabels }, @@ -97,7 +98,7 @@ describe("evidence export helper contract", () => { Cmd: EVIDENCE_EXPORT_HELPER_CMD, Labels: labels, User: EVIDENCE_EXPORT_HELPER_USER, - Env: null, + Env: EVIDENCE_EXPORT_HELPER_ENV, ExposedPorts: null, Healthcheck: null, Volumes: null @@ -107,7 +108,10 @@ describe("evidence export helper contract", () => { expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Cmd: ["bad"] }), base)).toBe(false); expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Labels: {} }), base)).toBe(false); expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Labels: { ...labels, extra: "bad" } }), base)).toBe(false); - expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Env: ["HOME=/bad"] }), base)).toBe(false); + for (const Env of [null, [], [EVIDENCE_EXPORT_HELPER_ENV[0], EVIDENCE_EXPORT_HELPER_ENV[0]], + [...EVIDENCE_EXPORT_HELPER_ENV, "HOME=/bad"], ["PATH=/bad"], ["TOKEN=private"]]) { + expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Env }), base)).toBe(false); + } expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, ExposedPorts: { "80/tcp": {} } }), base)).toBe(false); expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Healthcheck: { Test: ["CMD-SHELL", "echo"] } }), base)).toBe(false); expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Volumes: { "/tmp": {} } }), base)).toBe(false); @@ -117,6 +121,16 @@ describe("evidence export helper contract", () => { expect(isExpectedEvidenceExportImage(helperImageProjection({ ...validConfig, Extra: true } as Record), base)).toBe(false); }); + it("accepts a locally attested config identity without RepoDigests", () => { + const local = createEvidenceExportHelper({ artifactManifestDigest: `sha256:${"a".repeat(64)}`, + imageDigest: `sha256:${"b".repeat(64)}`, imageReference: `sha256:${"b".repeat(64)}`, + resultHandle: "opaque_aaaaaaaaaaaaaaaa" }); + const config = { Entrypoint: EVIDENCE_EXPORT_HELPER_ENTRYPOINT, Cmd: EVIDENCE_EXPORT_HELPER_CMD, + Labels: { [EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL]: "v1" }, User: EVIDENCE_EXPORT_HELPER_USER, + Env: EVIDENCE_EXPORT_HELPER_ENV, ExposedPorts: null, Healthcheck: null, Volumes: null }; + expect(isExpectedEvidenceExportImage(JSON.stringify([{ RepoDigests: null, Config: config }]), local)).toBe(true); + }); + it("accepts only an exact inspected helper container projection", () => { const spec = createEvidenceExportHelperSpec({ authority, diff --git a/src/target/evidenceExport.ts b/src/target/evidenceExport.ts index 5c82d0b4..8d4b15a1 100644 --- a/src/target/evidenceExport.ts +++ b/src/target/evidenceExport.ts @@ -27,7 +27,9 @@ export interface EvidenceExportOperationsOptions { readonly context: unknown; readonly executor: DockerResourceExecutor; readonly exportExecutor: DockerEvidenceExportExecutor; - readonly helperArtifactBundle: unknown; + readonly helperArtifactBundle?: unknown; + /** Spawnfile-owned local config identity. Never a public provider field. */ + readonly localHelper?: unknown; readonly helperArtifactManifestDigest: unknown; readonly helperArtifactContract: unknown; readonly artifactIdentityStore: DockerArtifactIdentityStore; @@ -65,7 +67,7 @@ interface EvidenceExportTestHooks { readonly beforePublishDirectorySync?: () => Promise | void; readonly beforeJournalComplete?: () => Promise | void; } -interface Options { readonly authorityStore: EvidenceExportAuthorityStore; readonly context: string; readonly executor: DockerResourceExecutor; readonly exportExecutor: DockerEvidenceExportExecutor; readonly helperArtifactBundle: HelperArtifactBundle; readonly helperArtifactManifestDigest: string; readonly helperArtifactContract: typeof EVIDENCE_EXPORT_HELPER_CONTRACT; readonly artifactIdentityStore: DockerArtifactIdentityStore; readonly journal: TargetJournalStore; readonly signal?: AbortSignal; readonly timeoutMs: number; readonly testHooks?: EvidenceExportTestHooks; } +interface Options { readonly authorityStore: EvidenceExportAuthorityStore; readonly context: string; readonly executor: DockerResourceExecutor; readonly exportExecutor: DockerEvidenceExportExecutor; readonly helperArtifactBundle?: HelperArtifactBundle; readonly localHelper?: EvidenceExportHelper; readonly helperArtifactManifestDigest: string; readonly helperArtifactContract: typeof EVIDENCE_EXPORT_HELPER_CONTRACT; readonly artifactIdentityStore: DockerArtifactIdentityStore; readonly journal: TargetJournalStore; readonly signal?: AbortSignal; readonly timeoutMs: number; readonly testHooks?: EvidenceExportTestHooks; } const fail = (): never => { throw new SpawnfileError("runtime_error", EVIDENCE_EXPORT_ERROR); }; const failWithCause = (error: unknown): never => { const summary = error instanceof Error ? `${error.name}: ${error.message}` : String(error); @@ -85,7 +87,7 @@ const same = (left: unknown, right: unknown): boolean => JSON.stringify(left) == const options = (raw: EvidenceExportOperationsOptions): Options => { if (typeof raw.context !== "string" || !CONTEXT.test(raw.context) || typeof raw.executor !== "function" || typeof raw.exportExecutor !== "function" || !raw.authorityStore || typeof raw.authorityStore.bindAdmission !== "function" || typeof raw.authorityStore.bindDestination !== "function" || typeof raw.authorityStore.requireDestination !== "function" || typeof raw.authorityStore.claimExport !== "function" || typeof raw.authorityStore.releaseExport !== "function" || typeof raw.authorityStore.loadAdmission !== "function" || typeof raw.authorityStore.loadIndex !== "function" || typeof raw.authorityStore.clearStaleExportClaim !== "function" || !raw.artifactIdentityStore || typeof raw.artifactIdentityStore.resolveOperation !== "function" || !raw.journal || typeof raw.journal.resolveCompletedReceipt !== "function" || typeof raw.timeoutMs !== "undefined" && (!Number.isSafeInteger(raw.timeoutMs) || (raw.timeoutMs as number) < 1 || (raw.timeoutMs as number) > 120_000)) return fail(); const timeoutMs = typeof raw.timeoutMs === "number" ? raw.timeoutMs : 10_000; - try { const rawBundle = raw.helperArtifactBundle; if (!rawBundle || typeof rawBundle !== "object" || Array.isArray(rawBundle) || Object.keys(rawBundle as Record).sort().join("\0") !== "operation_handle\0request_digest\0result_handle") return fail(); const bundle = rawBundle as Record; const helperArtifactBundle: HelperArtifactBundle = Object.freeze({ operationHandle: parseOpaqueTargetHandle(bundle.operation_handle), requestDigest: (() => { if (typeof bundle.request_digest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(bundle.request_digest)) return fail(); return bundle.request_digest as `sha256:${string}`; })(), resultHandle: parseOpaqueTargetHandle(bundle.result_handle) }); if (typeof raw.helperArtifactManifestDigest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(raw.helperArtifactManifestDigest) || raw.helperArtifactContract !== EVIDENCE_EXPORT_HELPER_CONTRACT) return fail(); return { authorityStore: raw.authorityStore, context: raw.context, executor: raw.executor, exportExecutor: raw.exportExecutor, helperArtifactBundle, helperArtifactManifestDigest: raw.helperArtifactManifestDigest, helperArtifactContract: EVIDENCE_EXPORT_HELPER_CONTRACT, artifactIdentityStore: raw.artifactIdentityStore, journal: raw.journal, signal: raw.signal, timeoutMs, testHooks: raw.testHooks }; } catch { return fail(); } + try { if (typeof raw.helperArtifactManifestDigest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(raw.helperArtifactManifestDigest) || raw.helperArtifactContract !== EVIDENCE_EXPORT_HELPER_CONTRACT) return fail(); const source = raw.localHelper as Record | undefined; const localHelper = source === undefined ? undefined : createEvidenceExportHelper({ artifactManifestDigest: source.artifactManifestDigest, imageDigest: source.image_digest, imageReference: source.image_reference, resultHandle: source.result_handle }); if (localHelper) { if (raw.helperArtifactBundle !== undefined || localHelper.artifactManifestDigest !== raw.helperArtifactManifestDigest) return fail(); return { authorityStore: raw.authorityStore, context: raw.context, executor: raw.executor, exportExecutor: raw.exportExecutor, localHelper, helperArtifactManifestDigest: raw.helperArtifactManifestDigest, helperArtifactContract: EVIDENCE_EXPORT_HELPER_CONTRACT, artifactIdentityStore: raw.artifactIdentityStore, journal: raw.journal, signal: raw.signal, timeoutMs, testHooks: raw.testHooks }; } const rawBundle = raw.helperArtifactBundle; if (!rawBundle || typeof rawBundle !== "object" || Array.isArray(rawBundle) || Object.keys(rawBundle as Record).sort().join("\0") !== "operation_handle\0request_digest\0result_handle") return fail(); const bundle = rawBundle as Record; const helperArtifactBundle: HelperArtifactBundle = Object.freeze({ operationHandle: parseOpaqueTargetHandle(bundle.operation_handle), requestDigest: (() => { if (typeof bundle.request_digest !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(bundle.request_digest)) return fail(); return bundle.request_digest as `sha256:${string}`; })(), resultHandle: parseOpaqueTargetHandle(bundle.result_handle) }); return { authorityStore: raw.authorityStore, context: raw.context, executor: raw.executor, exportExecutor: raw.exportExecutor, helperArtifactBundle, helperArtifactManifestDigest: raw.helperArtifactManifestDigest, helperArtifactContract: EVIDENCE_EXPORT_HELPER_CONTRACT, artifactIdentityStore: raw.artifactIdentityStore, journal: raw.journal, signal: raw.signal, timeoutMs, testHooks: raw.testHooks }; } catch { return fail(); } }; const request = (raw: unknown): ExportRequest => { const parsed = parseTargetResourceRequest(raw); if (parsed.operation !== "export_evidence_volume") return fail(); return parsed; }; const recovery = (raw: unknown): RecoverRequest => { const parsed = parseTargetResourceRequest(raw); if (parsed.operation !== "recover_operation") return fail(); return parsed; }; @@ -111,7 +113,8 @@ const inspectAuthority = async (authority: Awaited => { - const bundle = input.helperArtifactBundle; const binding = await input.artifactIdentityStore.resolveOperation(bundle.operationHandle, bundle.requestDigest); const journal = await input.journal.read(); + if (input.localHelper) return input.localHelper; + const bundle = input.helperArtifactBundle ?? fail(); const binding = await input.artifactIdentityStore.resolveOperation(bundle.operationHandle, bundle.requestDigest); const journal = await input.journal.read(); const completed = binding ? await input.journal.resolveCompletedReceipt({ operationHandle: binding.operationHandle, requestDigest: binding.requestDigest as `sha256:${string}` }) : null; if (!binding || binding.identityKind !== "oci_image_manifest" || !completed || binding.operationHandle !== bundle.operationHandle || binding.requestDigest !== bundle.requestDigest || binding.resultHandle !== bundle.resultHandle || binding.artifactManifestDigest !== input.helperArtifactManifestDigest || binding.selectedTargetHandle !== value.selected_target.handle || journal.run_id !== value.run_id || journal.descriptor_digest !== value.descriptor_digest || !same(journal.selected_target, value.selected_target) || completed.receipt.operation !== "resolve_world_artifact" || completed.receipt.result_handle !== bundle.resultHandle || completed.receipt.operation_handle !== bundle.operationHandle || completed.receipt.request_digest !== bundle.requestDigest || completed.receipt.run_id !== value.run_id || completed.receipt.descriptor_digest !== value.descriptor_digest || !same(completed.receipt.selected_target, value.selected_target)) return fail(); const expected = createDockerArtifactSpec({ artifactManifestDigest: binding.artifactManifestDigest, imageDigest: binding.imageDigest, imageReference: binding.imageReference, operationHandle: binding.operationHandle, requestDigest: binding.requestDigest, selectedTargetHandle: binding.selectedTargetHandle }); if (expected.resultHandle !== binding.resultHandle) return fail(); diff --git a/src/target/evidenceExportOperations.test.ts b/src/target/evidenceExportOperations.test.ts index 7f8848c3..23134bd0 100644 --- a/src/target/evidenceExportOperations.test.ts +++ b/src/target/evidenceExportOperations.test.ts @@ -204,7 +204,7 @@ describe("evidence export operation authority", () => { Entrypoint: ["/bin/spawnfile-export-helper"], Cmd: [], Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, - Env: null, + Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], ExposedPorts: null, Healthcheck: null, User: "65534:65534", @@ -222,7 +222,7 @@ describe("evidence export operation authority", () => { } if (args[2] === "container" && args[3] === "inspect") { const command = created[created.indexOf("--name") + 1]!; - return { stderr: "", stdout: JSON.stringify([{ Name: `/${command}`, Config: { Entrypoint: ["/bin/spawnfile-export-helper"], Cmd: [], Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, User: "65534:65534", Image: reference, Env: null, ExposedPorts: null, Healthcheck: null, Volumes: null }, HostConfig: { AutoRemove: false, NetworkMode: "none", ReadonlyRootfs: true, Privileged: false, CapAdd: null, CapDrop: ["ALL"], SecurityOpt: ["no-new-privileges=true"], PidsLimit: 64, Memory: 134217728, NanoCpus: 250_000_000, IpcMode: "none", PidMode: "", UTSMode: "", UsernsMode: "", CgroupnsMode: "private", Binds: null, VolumesFrom: null, ExtraHosts: null, Dns: null, Links: null, GroupAdd: null, Devices: null, DeviceRequests: null, PortBindings: null, PublishAllPorts: false, RestartPolicy: { Name: "no", MaximumRetryCount: 0 }, LogConfig: { Type: "none", Config: {} } }, Mounts: [{ Type: "volume", Name: volume!.name, Destination: "/spawnfile/evidence", RW: false }] }]) }; + return { stderr: "", stdout: JSON.stringify([{ Name: `/${command}`, Config: { Entrypoint: ["/bin/spawnfile-export-helper"], Cmd: [], Labels: { "spawnfile.target.evidence-export.helper-contract": "v1" }, User: "65534:65534", Image: reference, Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], ExposedPorts: null, Healthcheck: null, Volumes: null }, HostConfig: { AutoRemove: false, NetworkMode: "none", ReadonlyRootfs: true, Privileged: false, CapAdd: null, CapDrop: ["ALL"], SecurityOpt: ["no-new-privileges=true"], PidsLimit: 64, Memory: 134217728, NanoCpus: 250_000_000, IpcMode: "none", PidMode: "", UTSMode: "", UsernsMode: "", CgroupnsMode: "private", Binds: null, VolumesFrom: null, ExtraHosts: null, Dns: null, Links: null, GroupAdd: null, Devices: null, DeviceRequests: null, PortBindings: null, PublishAllPorts: false, RestartPolicy: { Name: "no", MaximumRetryCount: 0 }, LogConfig: { Type: "none", Config: {} } }, Mounts: [{ Type: "volume", Name: volume!.name, Destination: "/spawnfile/evidence", RW: false }] }]) }; } if (args[2] === "container" && args[3] === "rm") return { stderr: "", stdout: "removed" }; throw new Error("unexpected docker invocation"); diff --git a/src/target/evidenceExportOperationsTestKit.ts b/src/target/evidenceExportOperationsTestKit.ts index c8d727e0..3b4e3429 100644 --- a/src/target/evidenceExportOperationsTestKit.ts +++ b/src/target/evidenceExportOperationsTestKit.ts @@ -2,7 +2,7 @@ import { mkdtemp, realpath, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { EVIDENCE_EXPORT_MOUNT, EVIDENCE_EXPORT_HELPER_CMD, EVIDENCE_EXPORT_HELPER_CONTRACT, EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL, EVIDENCE_EXPORT_HELPER_ENTRYPOINT, EVIDENCE_EXPORT_HELPER_USER, createEvidenceExportHelper, createEvidenceExportHelperSpec, parseEvidenceVolumeAuthority } from "./evidenceExportProvider.js"; +import { EVIDENCE_EXPORT_MOUNT, EVIDENCE_EXPORT_HELPER_CMD, EVIDENCE_EXPORT_HELPER_CONTRACT, EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL, EVIDENCE_EXPORT_HELPER_ENTRYPOINT, EVIDENCE_EXPORT_HELPER_ENV, EVIDENCE_EXPORT_HELPER_USER, createEvidenceExportHelper, createEvidenceExportHelperSpec, parseEvidenceVolumeAuthority } from "./evidenceExportProvider.js"; import { createDockerArtifactSpec, initializeDockerArtifactIdentityStore } from "./dockerArtifactsProvider.js"; import { initializeEvidenceExportAuthorityStore, type EvidenceExportAdmission, type EvidenceExportAuthorityStoreOptions } from "./evidenceExportStore.js"; import { createDockerResourceSpec } from "./dockerResourcesProvider.js"; @@ -140,7 +140,7 @@ export const runLifecycleExport = async (input: LifecycleExportInput = {}): Prom Entrypoint: EVIDENCE_EXPORT_HELPER_ENTRYPOINT, Cmd: EVIDENCE_EXPORT_HELPER_CMD, Labels: { [EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL]: "v1" }, - Env: null, + Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], ExposedPorts: null, Healthcheck: null, User: EVIDENCE_EXPORT_HELPER_USER, @@ -305,7 +305,7 @@ export const helperProjection = (args: string[], volumeName: string, imageLabels Config: { Entrypoint: EVIDENCE_EXPORT_HELPER_ENTRYPOINT, Cmd: EVIDENCE_EXPORT_HELPER_CMD, - Env: null, + Env: EVIDENCE_EXPORT_HELPER_ENV, ExposedPorts: null, Healthcheck: null, Image: image, diff --git a/src/target/evidenceExportProvider.ts b/src/target/evidenceExportProvider.ts index eefac498..e74e3609 100644 --- a/src/target/evidenceExportProvider.ts +++ b/src/target/evidenceExportProvider.ts @@ -12,6 +12,11 @@ export const EVIDENCE_EXPORT_HELPER_CONTRACT_VERSION = "v1" as const; export const EVIDENCE_EXPORT_HELPER_CONTRACT_LABEL = "spawnfile.target.evidence-export.helper-contract" as const; export const EVIDENCE_EXPORT_HELPER_ENTRYPOINT: readonly string[] = Object.freeze(["/bin/spawnfile-export-helper"]); export const EVIDENCE_EXPORT_HELPER_CMD: readonly string[] = Object.freeze([]); +export const EVIDENCE_EXPORT_HELPER_PATH = + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" as const; +export const EVIDENCE_EXPORT_HELPER_ENV: readonly string[] = Object.freeze([ + `PATH=${EVIDENCE_EXPORT_HELPER_PATH}`, +]); export const EVIDENCE_EXPORT_HELPER_USER = "65534:65534" as const; const DIGEST = /^sha256:[a-f0-9]{64}$/u; const NAME = /^spfe_[a-f0-9]{58}$/u; @@ -51,8 +56,11 @@ const record = (raw: unknown): Readonly> => { }; const utf8 = (value: unknown): value is string => typeof value === "string" && Buffer.from(value, "utf8").toString("utf8") === value && Buffer.byteLength(value, "utf8") <= 32_768; const json = (raw: string): unknown => { if (!utf8(raw)) return fail(); try { return JSON.parse(raw) as unknown; } catch { return fail(); } }; +const exactHelperEnv = (raw: unknown): boolean => Array.isArray(raw) + && raw.length === 1 && raw[0] === EVIDENCE_EXPORT_HELPER_ENV[0]; export const createEvidenceExportHelper = (raw: { artifactManifestDigest: unknown; imageDigest: unknown; imageReference: unknown; resultHandle: unknown }): EvidenceExportHelper => { - if (typeof raw.artifactManifestDigest !== "string" || !DIGEST.test(raw.artifactManifestDigest) || typeof raw.imageDigest !== "string" || !DIGEST.test(raw.imageDigest) || typeof raw.imageReference !== "string" || !isImmutableDockerImageReference(raw.imageReference) || !raw.imageReference.endsWith(`@${raw.imageDigest}`)) return fail(); + const localConfig = raw.imageReference === raw.imageDigest; + if (typeof raw.artifactManifestDigest !== "string" || !DIGEST.test(raw.artifactManifestDigest) || typeof raw.imageDigest !== "string" || !DIGEST.test(raw.imageDigest) || typeof raw.imageReference !== "string" || !localConfig && (!isImmutableDockerImageReference(raw.imageReference) || !raw.imageReference.endsWith(`@${raw.imageDigest}`))) return fail(); return Object.freeze({ artifactManifestDigest: raw.artifactManifestDigest, image_digest: raw.imageDigest, image_reference: raw.imageReference, result_handle: parseOpaqueTargetHandle(raw.resultHandle) }); }; export const parseEvidenceVolumeAuthority = (raw: unknown): EvidenceVolumeAuthority => { @@ -96,7 +104,8 @@ export const parseEvidenceExportImageInspection = (stdout: string, helper: Evide const prepared = createEvidenceExportHelper({ artifactManifestDigest: helper.artifactManifestDigest, imageDigest: helper.image_digest, imageReference: helper.image_reference, resultHandle: helper.result_handle }); if (!Array.isArray(value) || value.length !== 1 || !exact(value[0], ["Config", "RepoDigests"])) return fail(); const image = value[0] as Record; - if (!Array.isArray(image.RepoDigests) || image.RepoDigests.length < 1 || image.RepoDigests.length > 32 || image.RepoDigests.some((value: unknown) => !isImmutableDockerImageReference(value))) return fail(); + const localConfig = prepared.image_reference === prepared.image_digest; + if (!localConfig && (!Array.isArray(image.RepoDigests) || image.RepoDigests.length < 1 || image.RepoDigests.length > 32 || image.RepoDigests.some((value: unknown) => !isImmutableDockerImageReference(value)))) return fail(); if (!exact(image.Config, ["Cmd", "Entrypoint", "Env", "ExposedPorts", "Healthcheck", "Labels", "User", "Volumes"])) return fail(); const config = image.Config as Record; const labels = record(config.Labels); @@ -104,8 +113,9 @@ export const parseEvidenceExportImageInspection = (stdout: string, helper: Evide if (!Array.isArray(config.Entrypoint) || JSON.stringify(config.Entrypoint) !== JSON.stringify(EVIDENCE_EXPORT_HELPER_ENTRYPOINT)) return fail(); if (config.Cmd !== null && !(Array.isArray(config.Cmd) && config.Cmd.length === 0)) return fail(); if (config.User !== EVIDENCE_EXPORT_HELPER_USER) return fail(); - if (config.Env !== null || config.ExposedPorts !== null || config.Healthcheck !== null || config.Volumes !== null) return fail(); - if (!image.RepoDigests.includes(prepared.image_reference)) return fail(); + if (!exactHelperEnv(config.Env) + || config.ExposedPorts !== null || config.Healthcheck !== null || config.Volumes !== null) return fail(); + if (!localConfig && !(image.RepoDigests as unknown[]).includes(prepared.image_reference)) return fail(); return { labels: Object.freeze({ ...labels }) }; }; export const isExpectedEvidenceExportImage = (stdout: string, helper: EvidenceExportHelper): boolean => { @@ -129,7 +139,8 @@ export const isExpectedEvidenceExportHelper = (stdout: string, spec: EvidenceExp return item.Name === `/${spec.containerName}` && config.Image === spec.imageReference && JSON.stringify(config.Entrypoint) === JSON.stringify(EVIDENCE_EXPORT_HELPER_ENTRYPOINT) && (cmd === null || (Array.isArray(cmd) && cmd.length === 0)) - && config.Env === null && config.ExposedPorts === null && config.Healthcheck === null && config.Volumes === null + && exactHelperEnv(config.Env) + && config.ExposedPorts === null && config.Healthcheck === null && config.Volumes === null && config.User === EVIDENCE_EXPORT_HELPER_USER && exact(config.Labels as Record, Object.keys(expectedLabels)) && Object.entries(expectedLabels).every(([key, value]) => config.Labels as Record !== null && (config.Labels as Record)[key] === value) diff --git a/src/target/publicArtifactSnapshot.test.ts b/src/target/publicArtifactSnapshot.test.ts index ed64d8fd..7b3dbfcc 100644 --- a/src/target/publicArtifactSnapshot.test.ts +++ b/src/target/publicArtifactSnapshot.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from "vitest"; import { MAX_TARGET_PUBLIC_ARTIFACT_BYTES, createCanonicalTargetPublicArtifactSnapshotBytes, + createCanonicalTargetPublicArtifactSnapshotResultBytes, createTargetPublicArtifactSnapshot, + createTargetPublicArtifactSnapshotNotPresent, createTargetPublicArtifactSnapshotRequestDigest, parseTargetPublicArtifactSnapshot, + parseTargetPublicArtifactSnapshotNotPresent, + parseTargetPublicArtifactSnapshotResult, parseTargetPublicArtifactSnapshotRequest } from "./publicArtifactSnapshot.js"; @@ -43,6 +47,7 @@ describe("target public artifact snapshot contract", () => { expect(JSON.parse(createCanonicalTargetPublicArtifactSnapshotBytes(snapshot))) .toEqual(snapshot); expect(parseTargetPublicArtifactSnapshot(snapshot)).toEqual(snapshot); + expect(parseTargetPublicArtifactSnapshotResult(snapshot)).toEqual(snapshot); }); it("admits and canonically transports a retained trace beyond the generic graph string bound", () => { @@ -64,11 +69,36 @@ describe("target public artifact snapshot contract", () => { expect(Buffer.from(reparsed.content_base64, "base64")).toEqual(content); }); + it("classifies only this correlated artifact request as not present", () => { + const parsed = parseTargetPublicArtifactSnapshotRequest(request); + const outcome = createTargetPublicArtifactSnapshotNotPresent(parsed); + expect(outcome).toEqual({ + artifact_id: "viewer_trace", + request_digest: createTargetPublicArtifactSnapshotRequestDigest(parsed), + run_id: "run-public-view", + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1" + }); + expect(parseTargetPublicArtifactSnapshotNotPresent(outcome)).toEqual(outcome); + expect(parseTargetPublicArtifactSnapshotResult(outcome)).toEqual(outcome); + expect(JSON.parse(createCanonicalTargetPublicArtifactSnapshotResultBytes(outcome))) + .toEqual(outcome); + expect(() => parseTargetPublicArtifactSnapshotNotPresent({ + ...outcome, + retry_after_ms: 1_000 + })).toThrow(); + expect(() => parseTargetPublicArtifactSnapshotNotPresent({ + ...outcome, + status: "not_present_yet" + })).toThrow(); + }); + it("rejects private paths, traversal, hostile shapes, oversize, and corrupt bytes", () => { for (const path of [ "/run/world/evidence/viewer.json", "/run/spawnfile-secrets/token", "/tmp/spawnfile-public/../secret", + "/tmp/spawnfile-public/nested/secret", "/tmp/spawnfile-public/" ]) { expect(() => parseTargetPublicArtifactSnapshotRequest({ diff --git a/src/target/publicArtifactSnapshot.ts b/src/target/publicArtifactSnapshot.ts index effaf294..68fcb696 100644 --- a/src/target/publicArtifactSnapshot.ts +++ b/src/target/publicArtifactSnapshot.ts @@ -15,6 +15,8 @@ export const TARGET_PUBLIC_ARTIFACT_SNAPSHOT_REQUEST_VERSION = "spawnfile.target-public-artifact-snapshot.request.v1" as const; export const TARGET_PUBLIC_ARTIFACT_SNAPSHOT_VERSION = "spawnfile.target-public-artifact-snapshot.v1" as const; +export const TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION = + "spawnfile.target-public-artifact-snapshot.not-present.v1" as const; export const TARGET_PUBLIC_ARTIFACT_ROOT = "/tmp/spawnfile-public" as const; export const MAX_TARGET_PUBLIC_ARTIFACT_BYTES = 131_072; @@ -23,10 +25,15 @@ const identifierSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); const mediaTypeSchema = z.string() .max(127) .regex(/^[a-z][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u); +const PUBLIC_ARTIFACT_PATH = /^\/tmp\/spawnfile-public\/[A-Za-z0-9][A-Za-z0-9._-]*$/u; const publicPathSchema = z.string().max(255) - .regex(/^\/tmp\/spawnfile-public\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u) - .refine((value) => !value.includes("//") && !value.endsWith("/") - && !value.split("/").some((part) => part === "." || part === "..")); + // This is intentionally a direct child of the isolated public tmpfs. A + // nested path would require a recursive openat-style resolver to keep every + // parent below the mount while an untrusted world is writing its output. + .regex(PUBLIC_ARTIFACT_PATH); + +export const isTargetPublicArtifactPath = (value: unknown): value is string => + typeof value === "string" && value.length <= 255 && PUBLIC_ARTIFACT_PATH.test(value); export const targetPublicArtifactSnapshotRequestSchema = z.object({ artifact: z.object({ @@ -66,10 +73,27 @@ export const targetPublicArtifactSnapshotSchema = z.object({ } }); +export const targetPublicArtifactSnapshotNotPresentSchema = z.object({ + artifact_id: identifierSchema, + request_digest: digestSchema, + run_id: runIdSchema, + status: z.literal("not_present"), + version: z.literal(TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION) +}).strict(); + +export const targetPublicArtifactSnapshotResultSchema = z.union([ + targetPublicArtifactSnapshotSchema, + targetPublicArtifactSnapshotNotPresentSchema +]); + export type TargetPublicArtifactSnapshotRequest = z.infer; export type TargetPublicArtifactSnapshot = z.infer; +export type TargetPublicArtifactSnapshotNotPresent = + z.infer; +export type TargetPublicArtifactSnapshotResult = + z.infer; const canonicalJsonValue = (value: unknown): string => { if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`; @@ -142,6 +166,20 @@ export const parseTargetPublicArtifactSnapshot = ( return targetPublicArtifactSnapshotSchema.parse(raw); }; +export const parseTargetPublicArtifactSnapshotNotPresent = ( + raw: unknown +): TargetPublicArtifactSnapshotNotPresent => { + assertOrdinaryPublicArtifactSnapshot(raw); + return targetPublicArtifactSnapshotNotPresentSchema.parse(raw); +}; + +export const parseTargetPublicArtifactSnapshotResult = ( + raw: unknown +): TargetPublicArtifactSnapshotResult => { + assertOrdinaryPublicArtifactSnapshot(raw); + return targetPublicArtifactSnapshotResultSchema.parse(raw); +}; + export const createTargetPublicArtifactSnapshotRequestDigest = ( raw: unknown ): `sha256:${string}` => `sha256:${createHash("sha256") @@ -170,6 +208,23 @@ export const createTargetPublicArtifactSnapshot = (input: { }); }; +export const createTargetPublicArtifactSnapshotNotPresent = ( + raw: unknown +): TargetPublicArtifactSnapshotNotPresent => { + const request = parseTargetPublicArtifactSnapshotRequest(raw); + return parseTargetPublicArtifactSnapshotNotPresent({ + artifact_id: request.artifact.id, + request_digest: createTargetPublicArtifactSnapshotRequestDigest(request), + run_id: request.run_id, + status: "not_present", + version: TARGET_PUBLIC_ARTIFACT_SNAPSHOT_NOT_PRESENT_VERSION + }); +}; + export const createCanonicalTargetPublicArtifactSnapshotBytes = ( raw: unknown ): string => canonicalJsonValue(parseTargetPublicArtifactSnapshot(raw)); + +export const createCanonicalTargetPublicArtifactSnapshotResultBytes = ( + raw: unknown +): string => canonicalJsonValue(parseTargetPublicArtifactSnapshotResult(raw)); diff --git a/src/target/topologyAttestation.test.ts b/src/target/topologyAttestation.test.ts index 119f0f1f..cf379b58 100644 --- a/src/target/topologyAttestation.test.ts +++ b/src/target/topologyAttestation.test.ts @@ -45,7 +45,7 @@ const worldProjection = (spec: ReturnType, DeviceCount: 0, DeviceRequestCount: 0, DnsCount: 0, Domainname: "", ExposedPortCount: 0, ExtraHostCount: 0, GroupAddCount: 0, Hostname: spec.containerName, Id: "9".repeat(64), Image: spec.imageReference, IpcMode: "none", Labels: spec.receiptLabels, LinkCount: 0, - Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, + Tmpfs: { "/tmp": "rw,noexec,nosuid,nodev,size=1m,mode=1777", "/tmp/spawnfile-public": "rw,noexec,nosuid,nodev,size=1m,mode=1777" }, LogType: "none", Mounts: [ { Destination: spec.evidenceMountPath, Name: source(spec.evidenceMountPath), RW: true, Type: "volume" }, { Destination: "/run/spawnfile-secrets", Name: source("/run/spawnfile-secrets"), RW: false, Type: "volume" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 69a678c0..aab5fb69 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -12,6 +12,7 @@ ], "exclude": [ "src/**/*.test.ts", + "src/**/*.test-helper.ts", "src/e2e/**/*.ts" ] } From ab49a229e587f386e5eb9e29533e6dad55c75db5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:13 +0200 Subject: [PATCH 03/34] fix(auth): serialize immutable target secret publication --- src/auth/targetSecretSourceFsPublish.test.ts | 71 ++++++- src/auth/targetSecretSourceFsPublish.ts | 6 +- ...rgetSecretSourceFsPublishImmutable.test.ts | 198 ++++++++++++++++++ .../targetSecretSourceFsPublishImmutable.ts | 71 +++++-- .../targetSecretSourceRecordPublish.test.ts | 80 ++++++- 5 files changed, 404 insertions(+), 22 deletions(-) diff --git a/src/auth/targetSecretSourceFsPublish.test.ts b/src/auth/targetSecretSourceFsPublish.test.ts index 9d0c1297..b6eb1392 100644 --- a/src/auth/targetSecretSourceFsPublish.test.ts +++ b/src/auth/targetSecretSourceFsPublish.test.ts @@ -1,7 +1,9 @@ import { chmod, link, lstat, mkdtemp, open, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as versionRecordParsers from "./targetSecretSourceVersionRecords.js"; import { TARGET_SECRET_SOURCE_ERROR, @@ -60,6 +62,45 @@ afterEach(async () => { }); describe("targetSecretSourceFsPublish", () => { + it("rejects malformed, equal, and byte-mismatched immutable handles before publication", async () => { + const { publisher } = await setup(); + const { input } = makeInput(3, 4); + for (const private_metadata of [ + { ...input.private_metadata, publication_handle: "malformed" }, + { ...input.private_metadata, source_version_handle: "malformed" }, + { publication_handle: input.private_metadata.source_version_handle, source_version_handle: input.private_metadata.source_version_handle } + ]) { + await expect(publisher.publishVersion({ ...input, private_metadata } as TargetSecretSourceFsPublishInput)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + } + const other = makeInput(5, 6); + await expect(publisher.publishVersion({ ...input, private_metadata: other.input.private_metadata })) + .rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + await expect(lstat(pathsFor(input).final)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects malformed publication envelopes before reading immutable handles", async () => { + const { publisher } = await setup(); + for (const input of [ + null, + {}, + { bytes: "not-bytes", private_metadata: {} }, + { bytes: new Uint8Array([1]), private_metadata: null }, + { bytes: new Uint8Array([1]), private_metadata: { publication_handle: 1, source_version_handle: 2 } } + ]) await expect(publisher.publishVersion(input as never)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + }); + + it("revalidates the embedded publication handle during immutable proof", async () => { + const { publisher } = await setup(); + const { input } = makeInput(7, 8); + const original = versionRecordParsers.parseTargetSecretSourceVersionRecordBytes; + const spy = vi.spyOn(versionRecordParsers, "parseTargetSecretSourceVersionRecordBytes").mockImplementation((bytes, expected) => ({ + ...original(bytes, expected), + publication_handle: makeInput(9, 10).version.private_metadata.publication_handle + })); + await expect(publisher.publishVersion(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + spy.mockRestore(); + }); + it("publishes only the canonical final and replays exact bytes", async () => { const { publisher } = await setup(); const { input } = makeInput(1, 2); @@ -110,6 +151,34 @@ describe("targetSecretSourceFsPublish", () => { expect((await lstat(paths.final)).nlink).toBe(1); }); + it("repeatedly converges under full-file publisher contention", async () => { + for (let round = 0; round < 20; round += 1) { + await setup(); + const { input } = makeInput(80 + round, 120 + round, new Uint8Array(32_768).fill(round + 1)); + const errors: unknown[] = []; + const contention = new Map(); + const publisherCount = round % 2 === 0 ? 16 : 32; + const publishers = await Promise.all(Array.from({ length: publisherCount }, (_, index) => initializeTargetSecretSourceFsPublish({ + contentionForTest: (reason) => contention.set(reason, (contention.get(reason) ?? 0) + 1), + errorForTest: (error) => errors.push(error), + hookForTest: async (phase) => { + if (phase === "before_contention_retry") { + await new Promise((resolve) => setTimeout(resolve, ((round + 1) * (index + 3)) % 4)); + } + } + }))); + const results = await Promise.allSettled(publishers.map((publisher) => publisher.publishVersion(input))); + if (results.some(({ status }) => status === "rejected")) { + throw new Error(`publisher contention failed: ${JSON.stringify(Object.fromEntries(contention))}`, { cause: errors[0] }); + } + const paths = pathsFor(input); + expect(await readFile(paths.final)).toEqual(Buffer.from(input.bytes)); + expect((await lstat(paths.final)).nlink).toBe(1); + await expect(lstat(paths.claim)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(paths.token)).rejects.toMatchObject({ code: "ENOENT" }); + } + }, 30_000); + it("reobserves a stale nlink-two claim after the token disappears", async () => { await setup(); const { input } = makeInput(72, 73, new Uint8Array(512).fill(9)); diff --git a/src/auth/targetSecretSourceFsPublish.ts b/src/auth/targetSecretSourceFsPublish.ts index 1478a20d..dcd38f9d 100644 --- a/src/auth/targetSecretSourceFsPublish.ts +++ b/src/auth/targetSecretSourceFsPublish.ts @@ -22,6 +22,8 @@ export interface TargetSecretSourceFsPublishInput { >; } export interface TargetSecretSourceFsPublishOptions { + readonly contentionForTest?: (reason: string, attempt: number) => void; + readonly errorForTest?: (error: unknown) => void; readonly hookForTest?: (phase: TargetSecretSourceFsPublishPhase, path: string) => Promise | void; readonly maxWriteBytesForTest?: number; } @@ -42,6 +44,7 @@ export const initializeTargetSecretSourceFsPublish = async ( resolveTargetSecretsRoot(), resolveTargetSecretVersionsDirectory() ], + contentionForTest: options.contentionForTest, hookForTest: options.hookForTest, maxWriteBytesForTest: options.maxWriteBytesForTest }); @@ -65,7 +68,8 @@ export const initializeTargetSecretSourceFsPublish = async ( } } }); - } catch { + } catch (error) { + options.errorForTest?.(error); fail(); } }; diff --git a/src/auth/targetSecretSourceFsPublishImmutable.test.ts b/src/auth/targetSecretSourceFsPublishImmutable.test.ts index 355042d1..37f1e9b8 100644 --- a/src/auth/targetSecretSourceFsPublishImmutable.test.ts +++ b/src/auth/targetSecretSourceFsPublishImmutable.test.ts @@ -161,4 +161,202 @@ describe("targetSecretSourceFsPublishImmutable", () => { await expect(lstat(secondToken)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(secondClaim)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("reobserves a token torn down before its creator fstats it", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_token_open" || cut) return; + cut = true; + await unlink(file); + } }); + const { input } = packet(); + await publisher.publishImmutable(input); + expect(cut).toBe(true); + expect((await lstat(input.final_path)).nlink).toBe(1); + }); + + it("reobserves a final disappearing between lstat and open", async () => { + const publisher = await setup(); + const { input } = packet(); + await publisher.publishImmutable(input); + let cut = false; + const recovering = await initializeCurrent({ hookForTest: async (phase, file) => { + if (phase !== "after_final_lstat" || cut) return; + cut = true; + await unlink(file); + } }); + await recovering.publishImmutable(input); + expect(cut).toBe(true); + expect(await readFile(input.final_path)).toEqual(Buffer.from(input.bytes)); + }); + + it("succeeds when peer cleanup wins the exact claim unlink", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "before_unlink_exact" || !file.endsWith(".claim") || cut) return; + cut = true; + await unlink(file); + } }); + const { input } = packet(); + await publisher.publishImmutable(input); + expect(cut).toBe(true); + await expect(lstat(`${input.final_path}.claim`)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("joins peer progress between final creation and the creator's first fstat", async () => { + await setup(); + const { input } = packet(2_048); + const helper = await initializeCurrent(); + let helped = false; + const creator = await initializeCurrent({ hookForTest: async (phase) => { + if (phase !== "after_final_open" || helped) return; + helped = true; + await helper.publishImmutable(input); + } }); + await creator.publishImmutable(input); + expect(helped).toBe(true); + expect(await readFile(input.final_path)).toEqual(Buffer.from(input.bytes)); + }); + + it("reobserves token teardown after its creator syncs", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_token_sync" || cut) return; + cut = true; + await unlink(file); + } }); + const { input } = packet(); + await publisher.publishImmutable(input); + expect(cut).toBe(true); + expect(await readFile(input.final_path)).toEqual(Buffer.from(input.bytes)); + }); + + it("rejects an oversized peer write after final creation", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_final_open" || cut) return; + cut = true; + await writeFile(file, new Uint8Array(32_768).fill(9)); + } }); + const { input } = packet(); + await expect(publisher.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(cut).toBe(true); + }); + + it("reobserves a disappearing election node and fails closed when its orphan cannot commit", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_zero_lstat" || cut || !file.includes(".token.")) return; + cut = true; + await unlink(file); + } }); + const { input } = packet(); + await expect(publisher.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(cut).toBe(true); + }); + + it("accepts a peer-won orphan-claim cleanup after exact commit", async () => { + const base = await setup(); + const { input } = packet(); + await base.publishImmutable(input); + const claim = `${input.final_path}.claim`; + await writeFile(claim, new Uint8Array(), { mode: 0o600 }); + let cut = false; + const recovering = await initializeCurrent({ hookForTest: async (phase, file) => { + if (phase !== "before_unlink_exact" || file !== claim || cut) return; + cut = true; + await unlink(file); + } }); + await recovering.publishImmutable(input); + expect(cut).toBe(true); + }); + + it("cleans a valid replacement orphan claim after the exact commit marker", async () => { + const base = await setup(); + const { input } = packet(); + await base.publishImmutable(input); + const claim = `${input.final_path}.claim`; + await writeFile(claim, new Uint8Array(), { mode: 0o600 }); + let cut = false; + const recovering = await initializeCurrent({ hookForTest: async (phase, file) => { + if (phase !== "before_unlink_exact" || file !== claim || cut) return; + cut = true; + await unlink(file); + await writeFile(file, new Uint8Array(), { mode: 0o600 }); + } }); + await recovering.publishImmutable(input); + expect(cut).toBe(true); + await expect(lstat(claim)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails re-proof when peer-won orphan cleanup coincides with final corruption", async () => { + const base = await setup(); + const { input } = packet(); + await base.publishImmutable(input); + const claim = `${input.final_path}.claim`; + await writeFile(claim, new Uint8Array(), { mode: 0o600 }); + let cut = false; + const recovering = await initializeCurrent({ hookForTest: async (phase, file) => { + if (phase !== "before_unlink_exact" || file !== claim || cut) return; + cut = true; + await unlink(file); + await writeFile(input.final_path, input.bytes.subarray(0, input.bytes.length - 1)); + } }); + await expect(recovering.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(cut).toBe(true); + }); + + it("fails re-proof when peer cleanup removes the remaining claim as the final changes", async () => { + let cut = false; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_token_cleanup" || cut) return; + cut = true; + await unlink(`${file.slice(0, file.indexOf(".token."))}.claim`); + const { input } = current!; + await writeFile(input.final_path, input.bytes.subarray(0, input.bytes.length - 1)); + } }); + const current = packet(); + await expect(publisher.publishImmutable(current.input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(cut).toBe(true); + }); + + it("reobserves an nlink-two remaining claim before peer teardown completes", async () => { + let extra = ""; + let teardown: Promise | undefined; + const publisher = await setup({ hookForTest: async (phase, file) => { + if (phase !== "after_token_cleanup" || teardown) return; + const claim = `${file.slice(0, file.indexOf(".token."))}.claim`; + extra = `${claim}.peer`; + await link(claim, extra); + teardown = new Promise((resolve, reject) => setTimeout(() => unlink(extra).then(resolve, reject), 0)); + } }); + const { input } = packet(); + await publisher.publishImmutable(input); + await teardown; + await expect(lstat(extra)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed for a stable foreign claim before exact commit", async () => { + await setup(); + const { input } = packet(); + await writeFile(`${input.final_path}.claim`, new Uint8Array(), { mode: 0o600 }); + const publisher = await initializeCurrent(); + await expect(publisher.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + await expect(lstat(input.final_path)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed when the final inode is replaced between named and opened observations", async () => { + const base = await setup(); + const { input } = packet(); + await base.publishImmutable(input); + let replaced = false; + const reader = await initializeCurrent({ hookForTest: async (phase, file) => { + if (phase !== "after_final_lstat" || replaced) return; + replaced = true; + await unlink(file); + await writeFile(file, input.bytes, { mode: 0o600 }); + } }); + await expect(reader.publishImmutable(input)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(replaced).toBe(true); + }); }); diff --git a/src/auth/targetSecretSourceFsPublishImmutable.ts b/src/auth/targetSecretSourceFsPublishImmutable.ts index 759ff633..b01eff5c 100644 --- a/src/auth/targetSecretSourceFsPublishImmutable.ts +++ b/src/auth/targetSecretSourceFsPublishImmutable.ts @@ -8,7 +8,9 @@ type Identity = Readonly<{ dev: number; ino: number; mode: number; nlink: number export type TargetSecretSourceFsPublishImmutablePhase = | "after_token_create" | "after_claim_link" | "after_claim_snapshot" | "after_mismatch_snapshot" | "after_exact_token_snapshot" | "after_final_create" | "after_partial_write" | "after_file_sync" - | "before_directory_sync" | "after_directory_sync" | "after_token_cleanup" | "after_claim_cleanup"; + | "before_directory_sync" | "after_directory_sync" | "after_token_cleanup" | "after_claim_cleanup" + | "after_zero_lstat" | "after_final_lstat" | "after_token_open" | "after_token_sync" | "after_final_open" | "before_unlink_exact" + | "before_contention_retry"; export interface TargetSecretSourceFsPublishImmutableInput { readonly bytes: Uint8Array; @@ -18,6 +20,7 @@ export interface TargetSecretSourceFsPublishImmutableInput { } export interface TargetSecretSourceFsPublishImmutableOptions { readonly directory_chain: readonly string[]; + readonly contentionForTest?: (reason: string, attempt: number) => void; readonly hookForTest?: (phase: TargetSecretSourceFsPublishImmutablePhase, path: string) => Promise | void; readonly maxWriteBytesForTest?: number; } @@ -54,7 +57,8 @@ const fileIdentity = ( || (value.mode & 0o7777) !== 0o600 || value.size < 0 || value.size > MAX_BYTES || (zero && value.size !== 0)) fail(); return { dev: value.dev, ino: value.ino, mode: value.mode, nlink: value.nlink, size: value.size, uid: value.uid }; }; -const yieldTurn = (): Promise => new Promise((resolve) => setImmediate(resolve)); +const contentionDelay = (attempt: number): Promise => + new Promise((resolve) => setTimeout(resolve, Math.min(5, 1 + Math.floor(attempt / 32)))); export const initializeTargetSecretSourceFsPublishImmutable = async ( options: TargetSecretSourceFsPublishImmutableOptions @@ -100,6 +104,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (beforeInfo.nlink === 0) { fileIdentity(beforeInfo, uid, [0], true); return null; } before = fileIdentity(beforeInfo, uid, links, true); } catch (error) { if (missing(error)) return null; return fail(); } + await options.hookForTest?.("after_zero_lstat", path); let fd; try { try { fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); } @@ -125,6 +130,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( const immediate = await snapshotZero(path, [1, 2]); if (immediate === null) { await syncDirectory(); return false; } if (!sameInode(immediate, expected) || !same(immediate, expected)) return false; + await options.hookForTest?.("before_unlink_exact", path); try { await unlink(path); } catch (error) { if (!missing(error)) fail(); await syncDirectory(); @@ -138,6 +144,7 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( let before: Identity; try { before = fileIdentity(await lstat(path), uid, [1]); } catch (error) { if (missing(error)) return "absent"; return fail(); } + await options.hookForTest?.("after_final_lstat", path); if (before.size > expected.length) fail(); let fd; let bytes: Uint8Array | undefined; try { @@ -157,7 +164,10 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (!same(opened, after) || !same(opened, named)) return "prefix"; for (let index = 0; index < bytes.length; index += 1) if (bytes[index] !== expected[index]) fail(); return bytes.length === expected.length ? "exact" : "prefix"; - } catch { return fail(); } finally { bytes?.fill(0); await fd?.close().catch(() => undefined); } + } catch (error) { + if (missing(error)) return "absent"; + return fail(); + } finally { bytes?.fill(0); await fd?.close().catch(() => undefined); } }; const createToken = async (token: string): Promise => { await checkChain(); @@ -165,8 +175,14 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( try { fd = await open(token, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); await fd.chmod(0o600); - const opened = fileIdentity(await fd.stat(), uid, [1], true); + await options.hookForTest?.("after_token_open", token); + // An identical publisher may link or finish tearing down this token as soon + // as O_EXCL makes the pathname visible, before its creator reaches fstat. + const openedInfo = await fd.stat(); + if (openedInfo.nlink === 0) { fileIdentity(openedInfo, uid, [0], true); return null; } + const opened = fileIdentity(openedInfo, uid, [1, 2], true); await fd.sync(); + await options.hookForTest?.("after_token_sync", token); const afterInfo = await fd.stat(); if (afterInfo.nlink === 0) { fileIdentity(afterInfo, uid, [0], true); return null; } const after = fileIdentity(afterInfo, uid, [1, 2], true); @@ -202,6 +218,12 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( await proveOwned(); const claim = `${final}.claim`; const token = `${final}.token.${publicationHandle}`; + const retryContention = async (attempt: number, contestedPath: string, reason: string): Promise => { + options.contentionForTest?.(reason, attempt); + await options.hookForTest?.("before_contention_retry", contestedPath); + if (attempt >= MAX_ATTEMPTS - 1) fail(); + await contentionDelay(attempt); + }; const proveFinal = async (): Promise => { if (await readFinal(final, owned!) !== "exact") return false; await proveOwned(); @@ -217,14 +239,19 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( return; } if (claimState !== null && tokenState === null) { - if (finalState !== "exact" || claimState.nlink !== 1) { await yieldTurn(); continue; } + if (finalState !== "exact" || claimState.nlink !== 1) { await retryContention(attempt, claim, "orphan-claim-not-cleanable"); continue; } if (!await proveFinal()) fail(); - if (!await unlinkExact(claim, claimState)) { await yieldTurn(); continue; } + if (!await unlinkExact(claim, claimState)) { + const latestClaim = await snapshotZero(claim, [1, 2]); + if (latestClaim === null) { if (!await proveFinal()) fail(); return; } + if (!sameInode(latestClaim, claimState)) fail(); + await retryContention(attempt, claim, "orphan-claim-cleanup-race"); continue; + } return; } let ownedToken = tokenState; if (ownedToken === null) ownedToken = await createToken(token); - if (ownedToken === null) { await yieldTurn(); continue; } + if (ownedToken === null) { await retryContention(attempt, token, "token-election-race"); continue; } let currentClaim = await snapshotZero(claim, [1, 2]); if (currentClaim === null && ownedToken.nlink === 1) { try { await checkChain(); await link(token, claim); } catch (error) { if (!exists(error)) fail(); } @@ -233,33 +260,35 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( currentClaim = await snapshotZero(claim, [1, 2]); ownedToken = await snapshotZero(token, [1, 2]); } - if (!currentClaim || !ownedToken) { await yieldTurn(); continue; } + if (!currentClaim || !ownedToken) { await retryContention(attempt, claim, "topology-observation-race"); continue; } if (currentClaim.dev !== ownedToken.dev || currentClaim.ino !== ownedToken.ino) { await options.hookForTest?.("after_mismatch_snapshot", token); if (ownedToken.nlink === 1) { const latest = await snapshotZero(token, [1, 2]); if (latest?.nlink === 1 && same(latest, ownedToken)) await unlinkExact(token, latest); } - await yieldTurn(); + await retryContention(attempt, claim, "foreign-token-race"); continue; } - if (currentClaim.nlink !== 2 || ownedToken.nlink !== 2) { await yieldTurn(); continue; } + if (currentClaim.nlink !== 2 || ownedToken.nlink !== 2) { await retryContention(attempt, claim, "link-count-race"); continue; } const state = await readFinal(final, owned); if (state === "absent") { let fd; try { fd = await open(final, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); await fd.chmod(0o600); - const empty = fileIdentity(await fd.stat(), uid, [1], true); + await options.hookForTest?.("after_final_open", final); + const created = fileIdentity(await fd.stat(), uid, [1]); + if (created.size > owned.length) fail(); await options.hookForTest?.("after_final_create", final); - const split = Math.max(1, Math.floor(owned.length / 2)); - let offset = 0; + const split = Math.max(created.size, 1, Math.floor(owned.length / 2)); + let offset = created.size; while (offset < split) offset += await writeSome(fd, owned, offset, split - offset); await options.hookForTest?.("after_partial_write", final); while (offset < owned.length) offset += await writeSome(fd, owned, offset, owned.length - offset); await fd.sync(); const complete = fileIdentity(await fd.stat(), uid, [1]); - if (empty.dev !== complete.dev || empty.ino !== complete.ino || complete.size !== owned.length) fail(); + if (created.dev !== complete.dev || created.ino !== complete.ino || complete.size !== owned.length) fail(); await options.hookForTest?.("after_file_sync", final); } catch (error) { if (!exists(error)) fail(); } finally { await fd?.close().catch(() => undefined); } } else if (state === "prefix") { @@ -275,19 +304,27 @@ export const initializeTargetSecretSourceFsPublishImmutable = async ( if (before.dev !== after.dev || before.ino !== after.ino || after.size !== owned.length) fail(); } catch { fail(); } finally { await fd?.close().catch(() => undefined); } } - if (!await proveFinal()) { await yieldTurn(); continue; } + if (!await proveFinal()) { await retryContention(attempt, final, "final-write-race"); continue; } await syncDirectory(); await options.hookForTest?.("after_directory_sync", final); const exactToken = await snapshotZero(token, [1, 2]); await options.hookForTest?.("after_exact_token_snapshot", token); const exactClaim = await snapshotZero(claim, [1, 2]); if (!exactToken || !exactClaim || exactToken.dev !== exactClaim.dev || exactToken.ino !== exactClaim.ino - || exactToken.nlink !== 2 || exactClaim.nlink !== 2) { await yieldTurn(); continue; } + || exactToken.nlink !== 2 || exactClaim.nlink !== 2) { await retryContention(attempt, claim, "cleanup-snapshot-race"); continue; } await unlinkExact(token, exactToken); await options.hookForTest?.("after_token_cleanup", token); const remainingClaim = await snapshotZero(claim, [1, 2]); if (remainingClaim === null) { if (!await proveFinal()) fail(); return; } - if (remainingClaim.nlink !== 1 || !await unlinkExact(claim, remainingClaim)) { await yieldTurn(); continue; } + if (remainingClaim.nlink !== 1) { + await retryContention(attempt, claim, "claim-final-cleanup-race"); continue; + } + if (!await unlinkExact(claim, remainingClaim)) { + const latestClaim = await snapshotZero(claim, [1, 2]); + if (latestClaim === null) { if (!await proveFinal()) fail(); return; } + if (!sameInode(latestClaim, remainingClaim)) fail(); + await retryContention(attempt, claim, "claim-final-cleanup-race"); continue; + } await options.hookForTest?.("after_claim_cleanup", claim); if (!await proveFinal()) fail(); return; diff --git a/src/auth/targetSecretSourceRecordPublish.test.ts b/src/auth/targetSecretSourceRecordPublish.test.ts index b76cc9fa..18ce5417 100644 --- a/src/auth/targetSecretSourceRecordPublish.test.ts +++ b/src/auth/targetSecretSourceRecordPublish.test.ts @@ -1,7 +1,10 @@ import { chmod, link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as grantRecordParsers from "./targetSecretSourceGrantRecords.js"; +import * as versionRecordParsers from "./targetSecretSourceVersionRecords.js"; import { resolveTargetSecretAliasPath, @@ -54,6 +57,76 @@ const records = () => { }; describe("targetSecretSourceRecordPublish", () => { + it("rejects non-byte inputs for every immutable record kind", async () => { + const publisher = await setup(); + for (const publish of [publisher.publishAlias, publisher.publishGrant, publisher.publishRedemption, publisher.publishRevocation]) { + await expect(publish("not-bytes" as never)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + } + }); + + it("rejects different bytes colliding on every immutable record handle", async () => { + const publisher = await setup(); + const value = records(); + await publisher.publishAlias(value.alias.private_bytes); + await publisher.publishGrant(value.grant.private_bytes); + await publisher.publishRedemption(value.redemption.private_bytes); + await publisher.publishRevocation(value.revocation.private_bytes); + + const otherVersion = createTargetSecretSourceVersionRecordBytes(new Uint8Array([9]), { + entropy: entropy(9), publicationEntropy: entropy(10) + }); + const aliasCollision = createTargetSecretSourceAliasRecordBytes(otherVersion.metadata, { + entropy: entropy(3), publicationEntropy: entropy(4) + }); + const grantCollision = createTargetSecretSourceGrantRecordBytes({ + descriptor_digest: digest("a"), name: "other", run_id: "run-1", scope: "world", + selected_target: { fingerprint: `sha256:${"b".repeat(32)}`, handle: handle("target"), version: "spawnfile.target-resource.selected-target.v1" }, + source_handle: value.alias.metadata.source_handle, source_version_handle: value.grant.metadata.source_version_handle + }, { publicationEntropy: entropy(5) }); + const authorization = { + descriptorDigest: digest("a"), name: "token", operationHandle: handle("operation"), requestDigest: digest("d"), + runId: "run-1", scope: "world", selectedTarget: { fingerprint: `sha256:${"b".repeat(32)}`, handle: handle("target") }, + sourceHandle: value.alias.metadata.source_handle, version: "spawnfile.target-secret-source.authorization.v1" + }; + const redemptionCollision = createTargetSecretSourceRedemptionRecordBytes(value.grant.metadata, authorization as never, { + publicationEntropy: entropy(6) + }); + const revocationCollision = createTargetSecretSourceRevocationRecordBytes( + { kind: "version", revoked_handle: value.alias.metadata.source_handle }, + { entropy: entropy(7), publicationEntropy: entropy(8) } + ); + for (const publish of [ + () => publisher.publishAlias(aliasCollision.private_bytes), + () => publisher.publishGrant(grantCollision.private_bytes), + () => publisher.publishRedemption(redemptionCollision.private_bytes), + () => publisher.publishRevocation(revocationCollision.private_bytes) + ]) await expect(publish()).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + }); + + it("revalidates embedded handles for every record kind during immutable proof", async () => { + const publisher = await setup(); + const value = records(); + const cases = [ + [versionRecordParsers, "parseTargetSecretSourceAliasRecordBytesForPublication", value.alias.private_bytes, publisher.publishAlias], + [grantRecordParsers, "parseTargetSecretSourceGrantRecordBytesForPublication", value.grant.private_bytes, publisher.publishGrant], + [grantRecordParsers, "parseTargetSecretSourceRedemptionRecordBytesForPublication", value.redemption.private_bytes, publisher.publishRedemption], + [grantRecordParsers, "parseTargetSecretSourceRevocationRecordBytesForPublication", value.revocation.private_bytes, publisher.publishRevocation] + ] as const; + for (const [module, name, bytes, publish] of cases) { + const parserModule = module as unknown as Record Record>; + const original = parserModule[name]!; + let calls = 0; + const spy = vi.spyOn(parserModule, name).mockImplementation((raw: Uint8Array) => { + const parsed = original(raw); + calls += 1; + return calls === 1 ? parsed : { ...parsed, publication_handle: handle("embedded-mismatch") }; + }); + await expect(publish(bytes)).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); + expect(calls).toBeGreaterThanOrEqual(2); + spy.mockRestore(); + } + }); + it("publishes and exactly replays all four parser-proven direct leaves", async () => { const publisher = await setup(); const value = records(); const cases = [ @@ -103,7 +176,7 @@ describe("targetSecretSourceRecordPublish", () => { await expect(publisher.publishAlias(new Uint8Array([...value.alias.private_bytes, 32]))).rejects.toThrow(TARGET_SECRET_SOURCE_ERROR); }); - it("routes every kind through prefix recovery, actual short writes, and repeated twelve-way joins", async () => { + it("routes every kind through prefix recovery, actual short writes, and repeated 16/32-way joins", async () => { await setup(); const value = records(); const makeCases = (publisher: Awaited>) => [ { bytes: value.alias.private_bytes, file: resolveTargetSecretAliasPath(value.alias.metadata.source_handle), publish: () => publisher.publishAlias(value.alias.private_bytes) }, @@ -126,7 +199,8 @@ describe("targetSecretSourceRecordPublish", () => { expect(await readFile(item.file)).toEqual(Buffer.from(item.bytes)); } for (let round = 0; round < 3; round += 1) { - const writers = await Promise.all(Array.from({ length: 12 }, () => initializeTargetSecretSourceRecordPublish())); + const writerCount = round % 2 === 0 ? 16 : 32; + const writers = await Promise.all(Array.from({ length: writerCount }, () => initializeTargetSecretSourceRecordPublish())); for (let index = 0; index < 4; index += 1) { await rm(makeCases(writers[0]!)[index]!.file, { force: true }); await Promise.all(writers.map((writer) => makeCases(writer)[index]!.publish())); From 990d691a8037404b2e4e62db13fcc7eafb42f979 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:21 +0200 Subject: [PATCH 04/34] feat(manifest): declare autonomous network resources --- src/compiler/buildCompilePlanTeams.test.ts | 41 +++++- src/compiler/buildCompilePlanTeams.ts | 16 ++- src/compiler/compilePlanHelpers.ts | 3 + .../moltnetAttachmentAuthPlan.test.ts | 2 +- src/compiler/moltnetClientConfig.ts | 3 +- src/compiler/moltnetConfigLowering.test.ts | 80 ++++++++++- src/compiler/moltnetConfigLowering.ts | 41 ++++-- src/compiler/moltnetNodeConfig.ts | 3 +- .../moltnetRepresentativeResolution.ts | 6 +- src/compiler/moltnetResolution.ts | 3 +- src/compiler/moltnetRoomMemberships.test.ts | 39 +++++ src/compiler/moltnetRoomMemberships.ts | 16 ++- .../moltnetRoomPolicyCompatibility.ts | 12 +- src/compiler/moltnetRuntimeConfig.ts | 23 ++- src/compiler/moltnetServerPlans.test.ts | 50 +++++++ src/compiler/moltnetServerPlans.ts | 32 +++++ .../organizationExternalParticipants.ts | 37 ++++- src/compiler/organizationIdentity.test.ts | 10 ++ .../organizationIdentityBranches.test.ts | 45 ++++++ src/compiler/workspaceBundleArtifacts.test.ts | 74 ++++++++++ src/compiler/workspaceBundleArtifacts.ts | 63 ++++++++ src/compiler/workspaceResources.ts | 23 +-- src/manifest/mcpSchemas.ts | 2 + src/manifest/renderSpawnfile.test.ts | 19 ++- src/manifest/renderSpawnfileNetworks.ts | 14 +- src/manifest/renderSpawnfileWorkspace.ts | 4 + src/manifest/scheduleSchemas.ts | 69 ++++++++- src/manifest/schemas.test.ts | 135 ++++++++++++++++++ src/manifest/schemas.ts | 6 +- src/manifest/teamNetworkAccessSchemas.ts | 11 ++ src/manifest/teamNetworkSchemas.ts | 28 ++++ src/manifest/teamNetworkServerSchemas.ts | 21 ++- src/manifest/workspaceSchemas.ts | 11 ++ src/runtime/daimon/scheduleAuthority.test.ts | 66 +++++++++ src/runtime/daimon/scheduleAuthority.ts | 22 +++ src/runtime/scheduleUtils.ts | 7 +- 36 files changed, 985 insertions(+), 52 deletions(-) create mode 100644 src/compiler/workspaceBundleArtifacts.test.ts create mode 100644 src/compiler/workspaceBundleArtifacts.ts create mode 100644 src/runtime/daimon/scheduleAuthority.test.ts create mode 100644 src/runtime/daimon/scheduleAuthority.ts diff --git a/src/compiler/buildCompilePlanTeams.test.ts b/src/compiler/buildCompilePlanTeams.test.ts index d532b4aa..165abcd6 100644 --- a/src/compiler/buildCompilePlanTeams.test.ts +++ b/src/compiler/buildCompilePlanTeams.test.ts @@ -79,6 +79,7 @@ describe("buildCompilePlanTeams", () => { provider: "moltnet", rooms: [ { + federation: ["partner-b", "partner-a"], id: "general", members: ["lead"], name: "General" @@ -99,7 +100,12 @@ describe("buildCompilePlanTeams", () => { id: "org", name: "org", provider: "moltnet", - rooms: [{ id: "general", members: ["lead"], name: "General" }], + rooms: [{ + federation: ["partner-a", "partner-b"], + id: "general", + members: ["lead"], + name: "General" + }], server: { auth: { mode: "none" }, listen: { bind: "127.0.0.1", port: 8787 }, @@ -149,4 +155,37 @@ describe("buildCompilePlanTeams", () => { expect(() => validateTeamNetworkRooms(resolved)) .toThrow(/Moltnet room general references unknown member unattached/); }); + + it("accepts a scoped remote member only through an included pairing", () => { + const resolved = createResolvedTeam({ + networks: [{ + id: "org", + name: "Org", + provider: "moltnet", + rooms: [{ + federation: ["peer-link"], + id: "research", + members: ["lead", "peer-network:remote-agent"] + }], + server: { + auth: { mode: "none" }, + listen: { bind: "127.0.0.1", port: 8787 }, + mode: "managed", + pairings: [{ + id: "peer-link", + remote_base_url: "https://sensor.invalid", + remote_network_id: "peer-network", + remote_network_name: "Partner Floor", + token_secret: "PEER_PAIR_TOKEN" + }], + store: { kind: "memory" } + } + }] + }); + + expect(() => validateTeamNetworkRooms(resolved)).not.toThrow(); + resolved.networks![0]!.rooms[0]!.federation = "none"; + expect(() => validateTeamNetworkRooms(resolved)) + .toThrow(/references unknown member peer-network:remote-agent/); + }); }); diff --git a/src/compiler/buildCompilePlanTeams.ts b/src/compiler/buildCompilePlanTeams.ts index 862ea8fe..95d6a11d 100644 --- a/src/compiler/buildCompilePlanTeams.ts +++ b/src/compiler/buildCompilePlanTeams.ts @@ -1,7 +1,13 @@ -import type { TeamManifest } from "../manifest/index.js"; +import { + isDeclaredPairedRemoteRoomMember, + type TeamManifest +} from "../manifest/index.js"; import { SpawnfileError } from "../shared/index.js"; -import type { ResolvedTeamNetwork, ResolvedTeamNode } from "./types.js"; +import type { + ResolvedTeamNetwork, + ResolvedTeamNode +} from "./types.js"; export const resolveTeamExternalIds = (manifest: TeamManifest): string[] => { const memberIds = manifest.members.map((member) => member.id); @@ -33,6 +39,9 @@ export const resolveTeamNetworks = (manifest: TeamManifest): ResolvedTeamNetwork name: network.name ?? network.id, provider: network.provider, rooms: network.rooms.map((room) => ({ + ...(room.federation + ? { federation: Array.isArray(room.federation) ? [...room.federation].sort() : room.federation } + : {}), id: room.id, members: [...room.members], ...(room.name ? { name: room.name } : {}), @@ -63,7 +72,8 @@ export const validateTeamNetworkRooms = (teamNode: ResolvedTeamNode): void => { for (const roomMemberId of room.members) { const resolvedMember = teamNode.members.find((member) => member.id === roomMemberId); if (!resolvedMember - && !isAttachedExternalRoomMember(teamNode, network.id, roomMemberId)) { + && !isAttachedExternalRoomMember(teamNode, network.id, roomMemberId) + && !isDeclaredPairedRemoteRoomMember(network, room, roomMemberId)) { throw new SpawnfileError( "validation_error", `Team ${teamNode.name} Moltnet room ${room.id} references unknown member ${roomMemberId}` diff --git a/src/compiler/compilePlanHelpers.ts b/src/compiler/compilePlanHelpers.ts index d50bdde6..4b41a8d2 100644 --- a/src/compiler/compilePlanHelpers.ts +++ b/src/compiler/compilePlanHelpers.ts @@ -83,6 +83,9 @@ const collectMoltnetSecretNames = ( if (server.mode === "managed") { for (const pairing of server.pairings ?? []) { secretNames.add(pairing.token_secret); + if (pairing.relay) { + secretNames.add(pairing.relay.token_secret); + } } if (server.store.kind === "postgres") { diff --git a/src/compiler/moltnetAttachmentAuthPlan.test.ts b/src/compiler/moltnetAttachmentAuthPlan.test.ts index 0998cd64..eaa8a10b 100644 --- a/src/compiler/moltnetAttachmentAuthPlan.test.ts +++ b/src/compiler/moltnetAttachmentAuthPlan.test.ts @@ -107,7 +107,7 @@ describe("Moltnet attachment credential plan validation", () => { ].join("\n") }, { - expected: /must include attach and write scopes/, + expected: /must include attach, write scopes exactly/, selected: "red-agent", token: [ " - id: red-agent", diff --git a/src/compiler/moltnetClientConfig.ts b/src/compiler/moltnetClientConfig.ts index 9f3bc211..e3c5acb7 100644 --- a/src/compiler/moltnetClientConfig.ts +++ b/src/compiler/moltnetClientConfig.ts @@ -100,7 +100,8 @@ const createAttachmentConfig = ( attachment.network, attachment.memberId, agentSlug, - attachment.auth?.tokenId + attachment.auth?.tokenId, + node.runtime.name ); return { diff --git a/src/compiler/moltnetConfigLowering.test.ts b/src/compiler/moltnetConfigLowering.test.ts index aea73c05..c54d61c3 100644 --- a/src/compiler/moltnetConfigLowering.test.ts +++ b/src/compiler/moltnetConfigLowering.test.ts @@ -4,6 +4,7 @@ import type { TeamNetworkServer } from "../manifest/index.js"; import { createMoltnetNativeServerConfig, + createMoltnetDaimonReceiptStorePath, createDefaultMoltnetStorePath, createMoltnetNodeConfigPath, createMoltnetOpenTokenDirectory, @@ -39,6 +40,15 @@ describe("moltnetConfigLowering", () => { .toBe("container/rootfs/var/lib/spawnfile/moltnet/nodes/root-team-org-net-field-rep.json"); }); + it("renders only collision-free absolute Daimon receipt-store paths", () => { + expect(createMoltnetDaimonReceiptStorePath("local", "relay-agent")) + .toBe("/var/lib/spawnfile/moltnet/networks/local/daimon-receipts/relay-agent.json"); + expect(() => createMoltnetDaimonReceiptStorePath("local", "../relay-agent")) + .toThrow("invalid Daimon receipt-store path segment"); + expect(() => createMoltnetDaimonReceiptStorePath("local net", "relay-agent")) + .toThrow("invalid Daimon receipt-store path segment"); + }); + it("renders listen addresses and base URLs for managed and external servers", () => { expect(renderMoltnetListenAddr(createManagedServer())).toBe("127.0.0.1:8787"); expect(renderMoltnetListenAddr(createManagedServer({ @@ -211,6 +221,7 @@ describe("moltnetConfigLowering", () => { tokens: [ { id: "operator", scopes: ["admin", "observe", "write"], secret: "OPERATOR_ENV" }, { agents: ["red"], id: "red", scopes: ["attach", "write"], secret: "RED_ENV" }, + { agents: ["red"], id: "daimon", scopes: ["attach", "observe", "write"], secret: "DAIMON_ENV" }, { agents: ["red"], id: "wrong-scope", scopes: ["attach"], secret: "WRONG_SCOPE_ENV" }, { agents: ["red"], id: "extra-scope", scopes: ["attach", "write", "admin"], secret: "EXTRA_SCOPE_ENV" }, { agents: ["blue"], id: "wrong-agent", scopes: ["attach", "write"], secret: "WRONG_AGENT_ENV" }, @@ -227,7 +238,11 @@ describe("moltnetConfigLowering", () => { mode: "bearer", tokenEnv: "RED_ENV" }); - for (const tokenId of ["operator", "wrong-scope", "extra-scope", "wrong-agent", "shared", "unbound", "missing", ""]) { + expect(resolveMoltnetClientAuth(server, "pitch", "red", undefined, "daimon", "daimon")) + .toMatchObject({ credentialId: "daimon", tokenEnv: "DAIMON_ENV" }); + expect(() => resolveMoltnetClientAuth(server, "pitch", "red", undefined, "red", "daimon")) + .toThrow(/attach, observe, write scopes exactly/u); + for (const tokenId of ["operator", "daimon", "wrong-scope", "extra-scope", "wrong-agent", "shared", "unbound", "missing", ""]) { expect.soft( () => resolveMoltnetClientAuth(server, "pitch", "red", undefined, tokenId), tokenId @@ -279,6 +294,7 @@ describe("moltnetConfigLowering", () => { networkName: "Org", rooms: [ { + federation: ["remote"], id: "agora", members: ["lead"], name: "Agora", @@ -355,6 +371,7 @@ describe("moltnetConfigLowering", () => { ], rooms: [ { + federation: ["remote"], id: "agora", members: ["lead"], name: "Agora", @@ -380,6 +397,67 @@ describe("moltnetConfigLowering", () => { }); }); + it("emits an explicit none stance for paired rooms without a federation grant", () => { + const lowered = createMoltnetNativeServerConfig({ + networkId: "org", + networkName: "Org", + rooms: [{ id: "private", members: ["lead"] }], + server: createManagedServer({ + pairings: [{ + id: "remote", + remote_base_url: "https://remote.example.com", + remote_network_id: "remote-org", + remote_network_name: "Remote Org", + token_secret: "REMOTE_PAIR_TOKEN" + }] + }) + }); + + expect(lowered.config).toMatchObject({ + rooms: [{ federation: "none", id: "private", members: ["lead"] }] + }); + }); + + it("lowers relay pairings with independent transport and pairing secrets", () => { + const lowered = createMoltnetNativeServerConfig({ + networkId: "collaboration-net", + networkName: "Collaboration Hub", + rooms: [{ federation: ["observer"], id: "research", members: ["lead"] }], + server: createManagedServer({ + pairings: [{ + id: "observer", + relay: { + room: "observer-room-v1", + token_secret: "MOLTNET_RELAY_TOKEN", + url: "wss://relay.example.com" + }, + remote_network_id: "observer-net", + remote_network_name: "Observer", + token_secret: "OBSERVER_PAIRING_TOKEN" + }] + }) + }); + + expect(lowered.secretPatches).toEqual([ + { envName: "OBSERVER_PAIRING_TOKEN", jsonPath: "pairings.0.token" }, + { envName: "MOLTNET_RELAY_TOKEN", jsonPath: "pairings.0.relay.token" } + ]); + expect(lowered.config).toMatchObject({ + pairings: [{ + id: "observer", + relay: { + room: "observer-room-v1", + token: "", + url: "wss://relay.example.com" + }, + remote_network_id: "observer-net", + remote_network_name: "Observer", + token: "" + }] + }); + expect(JSON.stringify(lowered.config)).not.toContain("remote_base_url"); + }); + it("lowers sqlite, json, and memory storage configs", () => { const sqlite = createMoltnetNativeServerConfig({ networkId: "sqlite-net", diff --git a/src/compiler/moltnetConfigLowering.ts b/src/compiler/moltnetConfigLowering.ts index 7313626d..9cfd47b2 100644 --- a/src/compiler/moltnetConfigLowering.ts +++ b/src/compiler/moltnetConfigLowering.ts @@ -17,6 +17,7 @@ export interface MoltnetClientAuthPlan { } export interface MoltnetNativeRoomConfig { + federation?: "all" | "none" | string[]; id: string; members: string[]; name?: string; @@ -54,6 +55,15 @@ export const createMoltnetOpenTokenPath = ( export const createMoltnetNetworkStateDirectory = (networkId: string): string => `/var/lib/spawnfile/moltnet/networks/${pathSafeSegment(networkId)}`; +export const createMoltnetDaimonReceiptStorePath = (networkId: string, memberId: string): string => { + const network = pathSafeSegment(networkId); + const member = pathSafeSegment(memberId); + if (network !== networkId || member !== memberId || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/u.test(memberId)) { + throw new SpawnfileError("validation_error", "invalid Daimon receipt-store path segment"); + } + return `${createMoltnetNetworkStateDirectory(networkId)}/daimon-receipts/${member}.json`; +}; + export const createDefaultMoltnetStorePath = ( networkId: string, kind: "json" | "sqlite", @@ -153,7 +163,8 @@ export const resolveMoltnetClientAuth = ( networkId: string, memberId: string, agentSlug?: string, - attachmentTokenId?: string + attachmentTokenId?: string, + runtimeName?: string ): MoltnetClientAuthPlan => { if (server.auth.mode === "none") { if (attachmentTokenId !== undefined) { @@ -188,14 +199,14 @@ export const resolveMoltnetClientAuth = ( `invalid Moltnet actor token ${attachmentTokenId} for ${memberId}: token id must exist exactly once` ); } - if ( - token.scopes.length !== 2 - || token.scopes[0] !== "attach" - || token.scopes[1] !== "write" - ) { + const requiredScopes = runtimeName === "daimon" + ? ["attach", "observe", "write"] + : ["attach", "write"]; + if (token.scopes.length !== requiredScopes.length + || token.scopes.some((scope, index) => scope !== requiredScopes[index])) { throw new SpawnfileError( "validation_error", - `invalid Moltnet actor token ${attachmentTokenId} for ${memberId}: token must include attach and write scopes exactly` + `invalid Moltnet actor token ${attachmentTokenId} for ${memberId}: token must include ${requiredScopes.join(", ")} scopes exactly` ); } if (token.agents?.length !== 1 || token.agents[0] !== memberId) { @@ -316,12 +327,21 @@ export const createMoltnetNativeServerConfig = ({ envName: pairing.token_secret, jsonPath: `pairings.${index}.token` }); + if (pairing.relay) { + secretPatches.push({ + envName: pairing.relay.token_secret, + jsonPath: `pairings.${index}.relay.token` + }); + } return { id: pairing.id, remote_network_id: pairing.remote_network_id, remote_network_name: pairing.remote_network_name, - remote_base_url: pairing.remote_base_url, + ...(pairing.remote_base_url ? { remote_base_url: pairing.remote_base_url } : {}), + ...(pairing.relay + ? { relay: { room: pairing.relay.room, token: "", url: pairing.relay.url } } + : {}), token: "" }; }); @@ -359,6 +379,11 @@ export const createMoltnetNativeServerConfig = ({ }, storage: storageConfigFor(networkId, server.store), rooms: rooms.map((room) => ({ + ...(room.federation !== undefined + ? { federation: room.federation } + : pairings.length > 0 + ? { federation: "none" } + : {}), id: room.id, ...(room.name ? { name: room.name } : {}), ...(room.visibility ? { visibility: room.visibility } : {}), diff --git a/src/compiler/moltnetNodeConfig.ts b/src/compiler/moltnetNodeConfig.ts index 92570caf..034e20fb 100644 --- a/src/compiler/moltnetNodeConfig.ts +++ b/src/compiler/moltnetNodeConfig.ts @@ -43,7 +43,8 @@ export const createMoltnetNodeConfigContent = ({ attachment.network, attachment.memberId, nodeSlug, - attachment.auth?.tokenId + attachment.auth?.tokenId, + agentNode.runtime.name ); const usesPerAttachmentOpenToken = clientAuth.mode === "open" && diff --git a/src/compiler/moltnetRepresentativeResolution.ts b/src/compiler/moltnetRepresentativeResolution.ts index 12fc323d..ebe88f62 100644 --- a/src/compiler/moltnetRepresentativeResolution.ts +++ b/src/compiler/moltnetRepresentativeResolution.ts @@ -133,7 +133,8 @@ export const resolveTeamRepresentatives = ( export const resolveMoltnetAttachments = ( attachments: ResolvedMoltnetAttachment[] | undefined, context: MoltnetTeamContext | undefined, - nodeName: string + nodeName: string, + runtimeName?: string ): ResolvedMoltnetAttachment[] | undefined => { if (!attachments || attachments.length === 0) { return undefined; @@ -160,7 +161,8 @@ export const resolveMoltnetAttachments = ( network.id, context.memberId, undefined, - attachment.auth?.tokenId + attachment.auth?.tokenId, + runtimeName ); } diff --git a/src/compiler/moltnetResolution.ts b/src/compiler/moltnetResolution.ts index 5571ffae..c6d552d0 100644 --- a/src/compiler/moltnetResolution.ts +++ b/src/compiler/moltnetResolution.ts @@ -338,7 +338,8 @@ export const resolvePlanMoltnetAttachments = (plan: CompilePlan): void => { teamName: context.teamName, teamSource: context.teamSource }, - agentNode.name + agentNode.name, + agentNode.runtime.name ); resolvedAttachments.push(...(resolved ?? [])); } diff --git a/src/compiler/moltnetRoomMemberships.test.ts b/src/compiler/moltnetRoomMemberships.test.ts index f7ccbab0..90b26f86 100644 --- a/src/compiler/moltnetRoomMemberships.test.ts +++ b/src/compiler/moltnetRoomMemberships.test.ts @@ -329,6 +329,45 @@ describe("moltnetRoomMemberships", () => { .toEqual(["lead", "world"]); }); + it("keeps a paired scoped remote member without synthesizing a local attachment", () => { + const lead = agent("lead"); + const server = createManagedServer(); + server.pairings = [{ + id: "peer-link", + remote_base_url: "https://sensor.invalid", + remote_network_id: "peer-network", + remote_network_name: "Partner Floor", + token_secret: "PEER_PAIR_TOKEN" + }]; + const org = team("org-team", { + members: [{ id: "lead", kind: "agent", nodeSource: lead.source, runtimeName: "openclaw" }], + networks: [{ + id: "org", + name: "Org", + provider: "moltnet", + rooms: [{ + federation: ["peer-link"], + id: "research", + members: ["lead", "peer-network:remote-agent"] + }], + server + }] + }); + const concretePlan = plan([node(lead), node(org)]); + const room = org.networks?.[0]?.rooms[0]; + if (!room) { + throw new Error("expected room"); + } + + expect(listConcreteMoltnetRoomMemberIds(concretePlan, org, "org", room)) + .toEqual(["lead", "peer-network:remote-agent"].sort()); + expect(resolveMoltnetRoomMemberships(concretePlan).map((row) => row.concreteMemberId)) + .toEqual(["lead"]); + concretePlan.moltnetRoomMemberships = resolveMoltnetRoomMemberships(concretePlan); + expect(listConcreteMoltnetRoomMemberIds(concretePlan, org, "org", room)) + .toEqual(["lead", "peer-network:remote-agent"].sort()); + }); + it("rejects concrete member listing when a nested team cannot be found", () => { const org = team("org-team", { members: [{ id: "missing", kind: "team", nodeSource: "/tmp/missing/Spawnfile", runtimeName: null }], diff --git a/src/compiler/moltnetRoomMemberships.ts b/src/compiler/moltnetRoomMemberships.ts index 3a1e3c47..779c73a0 100644 --- a/src/compiler/moltnetRoomMemberships.ts +++ b/src/compiler/moltnetRoomMemberships.ts @@ -10,6 +10,8 @@ import type { ResolvedTeamNetworkRoom, ResolvedTeamNode } from "./types.js"; +import { isDeclaredPairedRemoteRoomMember } from "../manifest/index.js"; + import { isAttachedExternalRoomMember } from "./buildCompilePlanTeams.js"; const hasOwn = (value: object, key: string): boolean => @@ -128,8 +130,12 @@ export const listConcreteMoltnetRoomMemberIds = ( room: ResolvedTeamNetworkRoom, memberships = plan.moltnetRoomMemberships ): string[] => { + const network = teamNode.networks?.find((candidate) => candidate.id === networkId); const externalMembers = room.members.filter((memberId) => isAttachedExternalRoomMember(teamNode, networkId, memberId)); + const remoteMembers = network + ? room.members.filter((memberId) => isDeclaredPairedRemoteRoomMember(network, room, memberId)) + : []; if (memberships) { return [ ...new Set( @@ -141,7 +147,8 @@ export const listConcreteMoltnetRoomMemberIds = ( && membership.roomId === room.id ) .map((membership) => membership.concreteMemberId), - ...externalMembers + ...externalMembers, + ...remoteMembers ] ) ].sort(); @@ -155,6 +162,10 @@ export const listConcreteMoltnetRoomMemberIds = ( concreteMembers.push(declaredSlot); continue; } + if (network && isDeclaredPairedRemoteRoomMember(network, room, declaredSlot)) { + concreteMembers.push(declaredSlot); + continue; + } throw new SpawnfileError( "validation_error", `Team ${teamNode.name} Moltnet room ${room.id} references unknown member ${declaredSlot}` @@ -205,6 +216,9 @@ export const resolveMoltnetRoomMemberships = ( )) { continue; } + if (isDeclaredPairedRemoteRoomMember(network, room, declaredSlot)) { + continue; + } throw new SpawnfileError( "validation_error", `Team ${teamNode.name} Moltnet room ${room.id} references unknown member ${declaredSlot}` diff --git a/src/compiler/moltnetRoomPolicyCompatibility.ts b/src/compiler/moltnetRoomPolicyCompatibility.ts index 47a39cdf..d4b34426 100644 --- a/src/compiler/moltnetRoomPolicyCompatibility.ts +++ b/src/compiler/moltnetRoomPolicyCompatibility.ts @@ -47,11 +47,15 @@ export const assertCompatibleMoltnetServer = ( export const assertCompatibleMoltnetRoomPolicy = ( networkId: string, roomId: string, - field: "visibility" | "write_policy", - existing: string | undefined, - next: string | undefined + field: "federation" | "visibility" | "write_policy", + existing: string | string[] | undefined, + next: string | string[] | undefined ): void => { - if (existing !== undefined && next !== undefined && existing !== next) { + if ( + existing !== undefined + && next !== undefined + && stableStringify(existing) !== stableStringify(next) + ) { throw new SpawnfileError( "validation_error", `Duplicate Moltnet network ${networkId} room ${roomId} declares conflicting ${field}: ${existing} vs ${next}` diff --git a/src/compiler/moltnetRuntimeConfig.ts b/src/compiler/moltnetRuntimeConfig.ts index ec3b9529..31f133c1 100644 --- a/src/compiler/moltnetRuntimeConfig.ts +++ b/src/compiler/moltnetRuntimeConfig.ts @@ -2,6 +2,7 @@ import { getRuntimeAdapter } from "../runtime/index.js"; import { SpawnfileError } from "../shared/index.js"; import type { CompilePlan, ResolvedAgentNode } from "./types.js"; +import { createMoltnetDaimonReceiptStorePath } from "./moltnetConfigLowering.js"; const INSTANCE_ROOT_PLACEHOLDER = ""; const CONFIG_FILE_PLACEHOLDER = ""; @@ -60,12 +61,28 @@ const resolveRuntimeInstancePaths = ( }; }; +const resolveDaimonAgentId = ( + plan: CompilePlan, + agentNode: ResolvedAgentNode +): string => { + const compiled = plan.nodes.find( + (node) => node.kind === "agent" && node.value.source === agentNode.source + ); + if (!compiled) { + throw new SpawnfileError( + "compile_error", + `Unable to resolve Daimon runtime agent identity for ${agentNode.name}` + ); + } + return compiled.id; +}; + export const resolveRuntimeConfig = ( plan: CompilePlan, agentNode: ResolvedAgentNode, nodeSlug: string, - _networkId: string, - _agentId: string + networkId: string, + moltnetAgentId: string ): Record => { switch (agentNode.runtime.name) { case "openclaw": { @@ -103,8 +120,10 @@ export const resolveRuntimeConfig = ( ); } return { + agent_id: resolveDaimonAgentId(plan, agentNode), control_url: `http://127.0.0.1:${port}`, kind: "daimon", + receipt_store_path: createMoltnetDaimonReceiptStorePath(networkId, moltnetAgentId), token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" }; } diff --git a/src/compiler/moltnetServerPlans.test.ts b/src/compiler/moltnetServerPlans.test.ts index e94c45ee..a0be8f58 100644 --- a/src/compiler/moltnetServerPlans.test.ts +++ b/src/compiler/moltnetServerPlans.test.ts @@ -92,6 +92,56 @@ const resolve = (plan: CompilePlan) => resolveMoltnetServerPlans( ); describe("Moltnet server-plan composition", () => { + it("preserves room federation and rejects unknown pairing references", () => { + const paired = server(); + if (paired.mode !== "managed") throw new Error("expected managed server"); + paired.pairings = [{ + id: "partner", + remote_base_url: "https://partner.example", + remote_network_id: "partner-net", + remote_network_name: "Partner", + token_secret: "PARTNER_TOKEN" + }]; + const plan = createPlan([network("pitch", "research", paired)], []); + if (plan.nodes[0]?.value.kind !== "team" || !plan.nodes[0].value.networks?.[0]?.rooms[0]) { + throw new Error("expected root room"); + } + plan.nodes[0].value.networks[0].rooms[0].federation = ["partner"]; + + expect(resolve(plan).get("pitch")?.rooms[0]?.federation).toEqual(["partner"]); + plan.nodes[0].value.networks[0].rooms[0].federation = ["unknown"]; + expect(() => resolve(plan)).toThrow(/federation references unknown pairing unknown/u); + }); + + it("rejects conflicting federation policy on a merged room", () => { + const plan = createPlan( + [network("pitch", "research", server())], + [network("pitch", "research", undefined)] + ); + if (plan.nodes[0]?.value.kind !== "team" || plan.nodes[1]?.value.kind !== "team") { + throw new Error("expected team nodes"); + } + plan.nodes[0].value.networks![0]!.rooms[0]!.federation = "none"; + plan.nodes[1].value.networks![0]!.rooms[0]!.federation = "all"; + + expect(() => resolve(plan)).toThrow(/conflicting federation/u); + }); + + it("rejects federation policy on an external server", () => { + const plan = createPlan( + [network("pitch", "research", { + auth: { mode: "none" }, + mode: "external", + url: "https://moltnet.example" + })], + [] + ); + if (plan.nodes[0]?.value.kind !== "team") throw new Error("expected team node"); + plan.nodes[0].value.networks![0]!.rooms[0]!.federation = "all"; + + expect(() => resolve(plan)).toThrow(/External Moltnet network pitch room research/u); + }); + it("merges a nested ownerless overlay into its root-owned server", () => { const plan = createPlan( [network("pitch", "root-room", server())], diff --git a/src/compiler/moltnetServerPlans.ts b/src/compiler/moltnetServerPlans.ts index 7919195a..c3920316 100644 --- a/src/compiler/moltnetServerPlans.ts +++ b/src/compiler/moltnetServerPlans.ts @@ -121,6 +121,7 @@ const mergeRooms = ( const existingRoom = serverPlan.rooms.find((entry) => entry.id === room.id); if (!existingRoom) { serverPlan.rooms.push({ + ...(room.federation ? { federation: room.federation } : {}), id: room.id, members: concreteMembers, ...(room.name ? { name: room.name } : {}), @@ -132,6 +133,13 @@ const mergeRooms = ( existingRoom.members = [ ...new Set([...existingRoom.members, ...concreteMembers]) ].sort(); + assertCompatibleMoltnetRoomPolicy( + network.id, + room.id, + "federation", + existingRoom.federation, + room.federation + ); assertCompatibleMoltnetRoomPolicy( network.id, room.id, @@ -146,6 +154,7 @@ const mergeRooms = ( existingRoom.write_policy, room.write_policy ); + existingRoom.federation ??= room.federation; existingRoom.visibility ??= room.visibility; existingRoom.write_policy ??= room.write_policy; } @@ -206,5 +215,28 @@ export const resolveMoltnetServerPlans = ( left.id.localeCompare(right.id) ); } + for (const serverPlan of serverPlans.values()) { + const pairingIds = new Set( + serverPlan.server.mode === "managed" + ? (serverPlan.server.pairings ?? []).map((pairing) => pairing.id) + : [] + ); + for (const room of serverPlan.rooms) { + if (serverPlan.server.mode === "external" && room.federation !== undefined) { + throw new SpawnfileError( + "validation_error", + `External Moltnet network ${serverPlan.networkId} room ${room.id} cannot declare federation` + ); + } + if (!Array.isArray(room.federation)) continue; + const unknownPairing = room.federation.find((pairingId) => !pairingIds.has(pairingId)); + if (unknownPairing) { + throw new SpawnfileError( + "validation_error", + `Moltnet network ${serverPlan.networkId} room ${room.id} federation references unknown pairing ${unknownPairing}` + ); + } + } + } return serverPlans; }; diff --git a/src/compiler/organizationExternalParticipants.ts b/src/compiler/organizationExternalParticipants.ts index d23ff44a..00e1bb63 100644 --- a/src/compiler/organizationExternalParticipants.ts +++ b/src/compiler/organizationExternalParticipants.ts @@ -18,7 +18,7 @@ const actorTokenFor = ( server: Extract, tokenId: string, memberId: string, - allowObserve = false, + observePolicy: "allow" | "forbid" | "require" = "forbid", ) => { const token = server.auth.tokens?.filter((entry) => entry.id === tokenId); if (token?.length !== 1) { @@ -27,14 +27,30 @@ const actorTokenFor = ( const selected = requiredOrganizationIdentity( token?.[0], `Moltnet actor token ${tokenId} must exist exactly once`, ); - const validScopes = exactOrganizationStrings(selected.scopes, ["attach", "write"]) - || allowObserve && exactOrganizationStrings(selected.scopes, ["attach", "observe", "write"]); + const validScopes = ( + observePolicy !== "require" && exactOrganizationStrings(selected.scopes, ["attach", "write"]) + ) || ( + observePolicy !== "forbid" + && exactOrganizationStrings(selected.scopes, ["attach", "observe", "write"]) + ); if (!validScopes || !exactOrganizationStrings(selected.agents, [memberId])) { organizationIdentityFail(`Moltnet actor token ${tokenId} has invalid scopes or agents for ${memberId}`); } return selected; }; +const isPairingAuthToken = ( + server: Extract, + token: NonNullable["auth"]["tokens"]>[number], +): boolean => token.agents === undefined + && exactOrganizationStrings(token.scopes, ["pair"]) + && (server.pairings ?? []).filter((pairing) => + pairing.id === token.id && pairing.token_secret === token.secret).length === 1; + +const isObserveOnlyConsoleToken = ( + token: NonNullable["auth"]["tokens"]>[number], +): boolean => token.agents === undefined && exactOrganizationStrings(token.scopes, ["observe"]); + const validateB31Networks = (plan: CompilePlan): Set => { const root = requiredOrganizationIdentity(rootOrganizationTeam(plan), "B31 root team is missing"); const services = requiredOrganizationIdentity(root.externalParticipants, "B31 participants are missing"); @@ -110,7 +126,12 @@ export const validateB31MoltnetAuth = (plan: CompilePlan): void => { } selectedActorKeys.add(actorKey); if (network?.server?.mode === "managed") { - const token = actorTokenFor(network.server, selectedTokenId, member.memberId); + const token = actorTokenFor( + network.server, + selectedTokenId, + member.memberId, + node?.runtimeName === "daimon" ? "require" : "forbid", + ); const selected = selectedByNetwork.get(attachment.network) ?? new Map(); const previous = selected.get(selectedTokenId); if (previous) { @@ -128,7 +149,7 @@ export const validateB31MoltnetAuth = (plan: CompilePlan): void => { for (const attachment of service.surfaces.moltnet) { const network = root.networks?.find((entry) => entry.id === attachment.network); if (network?.server?.mode !== "managed") continue; - const token = actorTokenFor(network.server, attachment.auth.token_id, service.id, true); + const token = actorTokenFor(network.server, attachment.auth.token_id, service.id, "allow"); if (token.id === "operator") { organizationIdentityFail(`B31 external participant ${service.id} must not use operator token`); } @@ -143,7 +164,9 @@ export const validateB31MoltnetAuth = (plan: CompilePlan): void => { const selected = selectedByNetwork.get(network.id); if (network.server?.mode !== "managed" || !selected) continue; for (const token of network.server.auth.tokens ?? []) { - if (token.id !== "operator" && !selected.has(token.id)) { + if (token.id !== "operator" && !selected.has(token.id) + && !isPairingAuthToken(network.server, token) + && !isObserveOnlyConsoleToken(token)) { organizationIdentityFail(`Moltnet actor token ${token.id} is not selected by exactly one actor`); } } @@ -190,7 +213,7 @@ export const resolveMoltnetExternalParticipantIntents = ( peers.sort(compareOrganizationIds); const network = root.networks?.find((entry) => entry.id === attachment.network); const token = network?.server?.mode === "managed" - ? actorTokenFor(network.server, attachment.auth.token_id, service.id, true) + ? actorTokenFor(network.server, attachment.auth.token_id, service.id, "allow") : undefined; result.push({ participant, networkId: attachment.network, tokenId: attachment.auth.token_id, tokenEnv: token?.secret ?? "", directMessagePeers: peers }); diff --git a/src/compiler/organizationIdentity.test.ts b/src/compiler/organizationIdentity.test.ts index 5b335a54..28d9cdbd 100644 --- a/src/compiler/organizationIdentity.test.ts +++ b/src/compiler/organizationIdentity.test.ts @@ -296,6 +296,16 @@ describe("organization identity", () => { expect(() => validateB31MoltnetAuth(current)).not.toThrow(); expect(resolveMoltnetExternalParticipantIntents(current)[0]?.tokenEnv) .toBe("PITCH_WORLD"); + + const daimon = createB31Plan(); + daimon.nodes.find((node) => node.kind === "agent")!.runtimeName = "daimon"; + expect(() => validateB31MoltnetAuth(daimon)).toThrow(/actor token red/u); + const daimonServer = rootOf(daimon).networks?.[0]?.server; + if (!daimonServer || daimonServer.mode !== "managed") throw new Error("expected managed server"); + const daimonToken = daimonServer.auth.tokens?.find((token) => token.id === "red"); + if (!daimonToken) throw new Error("expected Daimon token"); + daimonToken.scopes = ["attach", "observe", "write"]; + expect(() => validateB31MoltnetAuth(daimon)).not.toThrow(); }); it("rejects hostile operator, actor, token, and environment declarations", () => { diff --git a/src/compiler/organizationIdentityBranches.test.ts b/src/compiler/organizationIdentityBranches.test.ts index 31f0ed47..350b24fa 100644 --- a/src/compiler/organizationIdentityBranches.test.ts +++ b/src/compiler/organizationIdentityBranches.test.ts @@ -180,6 +180,51 @@ describe("organization identity defensive branches", () => { expect(() => validateB31MoltnetAuth(reused)).toThrow(/duplicate Moltnet token identity/u); }); + it("allows only matching pair-scoped transport credentials to remain actorless", () => { + const current = prepare(createPlan()); + const server = root(current).networks![0]!.server; + if (server?.mode !== "managed") throw new Error("expected managed server"); + server.pairings = [{ + id: "observer", + relay: { + room: "observer-room", + token_secret: "NETWORK_RELAY", + url: "wss://relay.example.test", + }, + remote_network_id: "observer-net", + remote_network_name: "Observer", + token_secret: "NETWORK_OBSERVER", + }]; + server.auth.tokens!.push({ + id: "observer", + scopes: ["pair"], + secret: "NETWORK_OBSERVER", + }); + expect(() => validateB31MoltnetAuth(current)).not.toThrow(); + + server.auth.tokens!.at(-1)!.secret = "NETWORK_WRONG"; + expect(() => validateB31MoltnetAuth(current)).toThrow(/not selected by exactly one actor/u); + }); + + it("allows only agentless observe-only console credentials to remain actorless", () => { + const current = prepare(createPlan()); + const server = root(current).networks![0]!.server; + if (server?.mode !== "managed") throw new Error("expected managed server"); + server.auth.tokens!.push({ + id: "console", + scopes: ["observe"], + secret: "NETWORK_CONSOLE", + }); + expect(() => validateB31MoltnetAuth(current)).not.toThrow(); + + server.auth.tokens!.at(-1)!.scopes = ["observe", "write"]; + expect(() => validateB31MoltnetAuth(current)).toThrow(/not selected by exactly one actor/u); + + server.auth.tokens!.at(-1)!.scopes = ["observe"]; + server.auth.tokens!.at(-1)!.agents = ["console"]; + expect(() => validateB31MoltnetAuth(current)).toThrow(/not selected by exactly one actor/u); + }); + it("skips external authorities for unmanaged networks", () => { const current = prepare(createPlan()); root(current).networks![0]!.server = { diff --git a/src/compiler/workspaceBundleArtifacts.test.ts b/src/compiler/workspaceBundleArtifacts.test.ts new file mode 100644 index 00000000..45f8f9de --- /dev/null +++ b/src/compiler/workspaceBundleArtifacts.test.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +import { stageWorkspaceBundles, validateWorkspaceBundleTar } from "./workspaceBundleArtifacts.js"; + +const run = promisify(execFile); + +describe("offline workspace bundles", () => { + it("returns false without declarations and rejects unsafe archive identities", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-bundle-invalid-")); try { + expect(await stageWorkspaceBundles(root, { nodes: [] } as never)).toBe(false); + expect(await stageWorkspaceBundles(path.join(root, "undefined-resources"), { nodes: [{ kind: "agent", value: {} }] } as never)).toBe(false); + expect(await stageWorkspaceBundles(path.join(root, "non-bundle"), { nodes: [{ kind: "agent", value: { workspaceResources: [{ kind: "volume" }] } }] } as never)).toBe(false); + await writeFile(path.join(root, "empty.tar"), Buffer.alloc(1024)); + const resource = { id: "bad", kind: "bundle", mode: "readonly", mount: "./bad", sha256: `sha256:${createHash("sha256").update(Buffer.alloc(1024)).digest("hex")}`, source: "empty.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(/empty/u); + await expect(stageWorkspaceBundles(path.join(root, "out2"), { nodes: [{ kind: "agent", value: { workspaceResources: [{ ...resource, source: "missing.tar" }] } }] } as never)).rejects.toThrow(); + await expect(stageWorkspaceBundles(path.join(root, "out3"), { nodes: [{ kind: "agent", value: { workspaceResources: [resource, { ...resource, source: "other.tar" }] } }] } as never)).rejects.toThrow(/multiple sources/u); + const unsafe = Buffer.alloc(1024); unsafe.write("../escape", 0, "ascii"); unsafe.write("00000000000", 124, "ascii"); unsafe[156] = "2".charCodeAt(0); await writeFile(path.join(root, "unsafe.tar"), unsafe); + const unsafeResource = { ...resource, source: "unsafe.tar", sha256: `sha256:${createHash("sha256").update(unsafe).digest("hex")}` }; + await expect(stageWorkspaceBundles(path.join(root, "out4"), { nodes: [{ kind: "agent", value: { workspaceResources: [unsafeResource] } }] } as never)).rejects.toThrow(/unsafe tar entry/u); + const link = Buffer.from(unsafe); link.fill(0, 0, 100); link.write("link", 0, "ascii"); const linkResource = { ...resource, source: "link.tar", sha256: `sha256:${createHash("sha256").update(link).digest("hex")}` }; await writeFile(path.join(root, "link.tar"), link); + await expect(stageWorkspaceBundles(path.join(root, "out-link"), { nodes: [{ kind: "agent", value: { workspaceResources: [linkResource] } }] } as never)).rejects.toThrow(/unsafe tar entry/u); + const absolute = Buffer.from(unsafe); absolute.fill(0, 0, 100); absolute.write("/absolute", 0, "ascii"); absolute[156] = "0".charCodeAt(0); const absoluteResource = { ...resource, source: "absolute.tar", sha256: `sha256:${createHash("sha256").update(absolute).digest("hex")}` }; await writeFile(path.join(root, "absolute.tar"), absolute); + await expect(stageWorkspaceBundles(path.join(root, "out-absolute"), { nodes: [{ kind: "agent", value: { workspaceResources: [absoluteResource] } }] } as never)).rejects.toThrow(/unsafe tar entry/u); + const unnamed = Buffer.alloc(1024); unnamed[156] = "0".charCodeAt(0); const unnamedResource = { ...resource, source: "unnamed.tar", sha256: `sha256:${createHash("sha256").update(unnamed).digest("hex")}` }; await writeFile(path.join(root, "unnamed.tar"), unnamed); + await expect(stageWorkspaceBundles(path.join(root, "out-unnamed"), { nodes: [{ kind: "agent", value: { workspaceResources: [unnamedResource] } }] } as never)).rejects.toThrow(/unsafe tar entry/u); + const truncated = Buffer.alloc(1024); truncated.write("file", 0, "ascii"); truncated.write("00000002000", 124, "ascii"); truncated[156] = "0".charCodeAt(0); const truncatedResource = { ...resource, source: "truncated.tar", sha256: `sha256:${createHash("sha256").update(truncated).digest("hex")}` }; await writeFile(path.join(root, "truncated.tar"), truncated); + await expect(stageWorkspaceBundles(path.join(root, "out-truncated"), { nodes: [{ kind: "agent", value: { workspaceResources: [truncatedResource] } }] } as never)).rejects.toThrow(/invalid|truncated/u); + const directoryResource = { ...resource, source: ".", sha256: `sha256:${"0".repeat(64)}` }; + await expect(stageWorkspaceBundles(path.join(root, "out5"), { nodes: [{ kind: "agent", value: { workspaceResources: [directoryResource] } }] } as never)).rejects.toThrow(/regular tar/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("stages only the exact checksum-pinned all-input tar bytes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-bundle-")); + try { + await writeFile(path.join(root, "tracked.txt"), "tracked"); await writeFile(path.join(root, "untracked.txt"), "untracked"); + await run("tar", ["--format=ustar", "-cf", "bundle.tar", "tracked.txt", "untracked.txt"], { cwd: root }); + const bytes = await readFile(path.join(root, "bundle.tar")); const sha256 = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + const rewriteHeader = (source: Buffer, offset: number, mutate: (header: Buffer) => void): Buffer => { const result = Buffer.from(source), header = result.subarray(offset, offset + 512); mutate(header); header.fill(32, 148, 156); let sum = 0; for (const byte of header) sum += byte; header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, "ascii"); return result; }; + const hostile = [ + rewriteHeader(bytes, 0, (header) => { header.fill(0, 345, 500); header.write("..", 345, "ascii"); }), + rewriteHeader(bytes, 0, (header) => { header.fill(0, 0, 100); header.write("/absolute", 0, "ascii"); }), + rewriteHeader(bytes, 1024, (header) => { header.copy(header, 0, 0, 100); header.fill(0, 0, 100); header.write("tracked.txt", 0, "ascii"); }), + rewriteHeader(bytes, 0, (header) => { header[156] = "2".charCodeAt(0); }), + rewriteHeader(bytes, 0, (header) => { header.write("z", 124, "ascii"); }), + Buffer.concat([bytes, Buffer.from([1])]) + ]; + const badChecksum = Buffer.from(bytes); badChecksum[0] ^= 1; hostile.push(badChecksum); + for (const candidate of hostile) expect(() => validateWorkspaceBundleTar(candidate)).toThrow(); + const emptyNumeric = rewriteHeader(bytes, 0, (header) => header.fill(0, 108, 116)); expect(() => validateWorkspaceBundleTar(emptyNumeric)).not.toThrow(); + const nullType = rewriteHeader(bytes, 0, (header) => { header[156] = 0; }); expect(() => validateWorkspaceBundleTar(nullType)).not.toThrow(); + const fullName = rewriteHeader(bytes, 0, (header) => { header.fill("a".charCodeAt(0), 0, 100); }); expect(() => validateWorkspaceBundleTar(fullName)).not.toThrow(); + const directoryHeader = rewriteHeader(bytes, 0, (header) => { header.fill(0, 0, 100); header.write("directory/", 0, "ascii"); header.fill(0, 124, 136); header.write("00000000000", 124, "ascii"); header[156] = "5".charCodeAt(0); }).subarray(0, 512); + expect(() => validateWorkspaceBundleTar(Buffer.concat([directoryHeader, Buffer.alloc(1024)]))).not.toThrow(); + const directoryWithData = rewriteHeader(bytes, 0, (header) => { header[156] = "5".charCodeAt(0); }); expect(() => validateWorkspaceBundleTar(directoryWithData)).toThrow(); + const oversizedEntry = rewriteHeader(bytes, 0, (header) => { header.fill(0, 124, 136); header.write("77777777777", 124, "ascii"); }); expect(() => validateWorkspaceBundleTar(oversizedEntry)).toThrow(/truncated/u); + const unterminated = oversizedEntry.subarray(0, 1024); expect(() => validateWorkspaceBundleTar(unterminated)).toThrow(/truncated/u); + const trailingGarbage = Buffer.from(bytes); trailingGarbage[trailingGarbage.length - 1] = 1; expect(() => validateWorkspaceBundleTar(trailingGarbage)).toThrow(/trailing/u); + const resource = { id: "project", kind: "bundle", mode: "readonly", mount: "./project", sha256, source: "bundle.tar", sharing: "per_agent", scope: { kind: "agent", key: path.join(root, "Agentfile"), name: "agent" } }; + const output = path.join(root, "out"); + expect(await stageWorkspaceBundles(output, { nodes: [{ kind: "agent", value: { workspaceResources: [resource] } }] } as never)).toBe(true); + expect(await stageWorkspaceBundles(path.join(root, "duplicate-source"), { nodes: [{ kind: "agent", value: { workspaceResources: [resource, resource] } }] } as never)).toBe(true); + expect(await readFile(path.join(output, "container/workspace-bundles", `${sha256.slice(7)}.tar`))).toEqual(bytes); + await writeFile(path.join(root, "bundle.tar"), Buffer.concat([bytes, Buffer.from("drift")])); + 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 }); } + }); +}); diff --git a/src/compiler/workspaceBundleArtifacts.ts b/src/compiler/workspaceBundleArtifacts.ts new file mode 100644 index 00000000..116c4993 --- /dev/null +++ b/src/compiler/workspaceBundleArtifacts.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { SpawnfileError } from "../shared/index.js"; +import type { CompilePlan } from "./types.js"; + +const CAP = 67_108_864, BLOCK = 512; +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 => { + const value = field.toString("ascii").replace(/\0.*$/u, "").trim(); + if (value === "" && allowEmpty) return 0; + if (!/^[0-7]+$/u.test(value)) fail(); + const parsed = Number.parseInt(value, 8); if (!Number.isSafeInteger(parsed) || parsed < 0) fail(); return parsed; +}; +const validPath = (value: string, directory: boolean): string => { + const name = directory && value.endsWith("/") ? value.slice(0, -1) : value; + if (!name || name.startsWith("/") || name.includes("\\") || name.includes("\0")) fail(); + const parts = name.split("/"); if (parts.some((part) => !part || part === "." || part === "..")) fail(); return parts.join("/"); +}; +const validChecksum = (header: Buffer): boolean => { + const expected = octal(header.subarray(148, 156)); let actual = 0; + for (let index = 0; index < BLOCK; index += 1) actual += index >= 148 && index < 156 ? 32 : header[index]!; + return actual === expected; +}; + +export const validateWorkspaceBundleTar = (bytes: Buffer): void => { + if (bytes.length < BLOCK * 2 || bytes.length % BLOCK !== 0) fail("Workspace bundle is truncated or lacks exact ustar termination"); + let offset = 0, entries = 0, terminated = false; const names = new Set(); + while (offset + BLOCK <= bytes.length) { + const header = bytes.subarray(offset, offset + BLOCK); + if (header.every((byte) => byte === 0)) { + if (offset + BLOCK * 2 > bytes.length || !bytes.subarray(offset, offset + BLOCK * 2).every((byte) => byte === 0) || !bytes.subarray(offset + BLOCK * 2).every((byte) => byte === 0)) fail("Workspace bundle has invalid termination or trailing bytes"); + terminated = true; break; + } + if (textField(header.subarray(257, 263)) !== "ustar" || !validChecksum(header)) fail(); + for (const [start, end, empty] of [[100, 108, false], [108, 116, true], [116, 124, true], [124, 136, false], [136, 148, true], [329, 337, true], [337, 345, true]] as const) octal(header.subarray(start, end), empty); + const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]!); if (type !== "0" && type !== "5") fail(); + 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"); + } + if (!terminated) fail("Workspace bundle is truncated or lacks exact ustar termination"); if (entries === 0) fail("Workspace bundle is empty"); +}; + +export const stageWorkspaceBundles = async (outputDirectory: string, plan: CompilePlan): Promise => { + const bundles = new Map(); + for (const node of plan.nodes) if (node.kind === "agent") for (const resource of node.value.workspaceResources ?? []) { + if (resource.kind !== "bundle") continue; + const source = path.resolve(path.dirname(resource.scope.key), resource.source), prior = bundles.get(resource.sha256); + if (prior && prior !== source) throw new SpawnfileError("validation_error", "Workspace bundle digest maps to multiple sources"); bundles.set(resource.sha256, source); + } + if (bundles.size === 0) return false; + const destination = path.join(outputDirectory, "container/workspace-bundles"); await mkdir(destination, { recursive: true }); + for (const [identity, source] of bundles) { + const info = await stat(source); if (!info.isFile() || info.size < 1 || info.size > CAP) throw new SpawnfileError("validation_error", "Workspace bundle must be a bounded regular tar file"); + const bytes = await readFile(source); const actual = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; if (actual !== identity) throw new SpawnfileError("validation_error", "Workspace bundle checksum mismatch"); + validateWorkspaceBundleTar(bytes); + await copyFile(source, path.join(destination, `${identity.slice(7)}.tar`)); + } + return true; +}; diff --git a/src/compiler/workspaceResources.ts b/src/compiler/workspaceResources.ts index 3ed1e658..81017905 100644 --- a/src/compiler/workspaceResources.ts +++ b/src/compiler/workspaceResources.ts @@ -19,16 +19,19 @@ export type ResolvedWorkspaceResource = TeamWorkspaceResource & { }; export interface WorkspaceResourcePlan { + archivePath?: string; backingPath: string; branch?: string; id: string; - kind: "git" | "volume"; + kind: "bundle" | "git" | "volume"; linkPath: string; mode: "mutable" | "readonly"; mount: string; name?: string; ref?: string; sharing: WorkspaceResourceSharing; + sha256?: string; + source?: string; tag?: string; url?: string; } @@ -47,6 +50,7 @@ const normalizeMount = (value: string): string => { }; const normalizeResourceIdentity = (resource: ResolvedWorkspaceResource): string => { + if (resource.kind === "bundle") return JSON.stringify({ kind: resource.kind, mode: resource.mode, mount: normalizeMount(resource.mount), sha256: resource.sha256, source: resource.source, sharing: resource.sharing }); if (resource.kind === "git") { return JSON.stringify({ branch: resource.branch?.trim() ?? "", @@ -81,12 +85,9 @@ const resolveSharing = (resource: TeamWorkspaceResource): WorkspaceResourceShari const toResolvedResource = ( resource: TeamWorkspaceResource, scope: WorkspaceResourceScope -): ResolvedWorkspaceResource => ({ - ...resource, - mount: normalizeMount(resource.mount), - scope, - sharing: resolveSharing(resource) -}); +): ResolvedWorkspaceResource => resource.kind === "bundle" + ? { ...resource, mount: normalizeMount(resource.mount), scope, sharing: "per_agent" } + : { ...resource, mount: normalizeMount(resource.mount), scope, sharing: resolveSharing(resource) }; const createPathSegment = (value: string): string => { const slug = slugify(value); @@ -189,7 +190,13 @@ export const toWorkspaceResourcePlan = ( ...(resource.tag ? { tag: resource.tag } : {}), url: resource.url } - : { + : resource.kind === "bundle" ? { + archivePath: `/opt/spawnfile/workspace-bundles/${resource.sha256.slice(7)}.tar`, + backingPath: resolveBackingPath(resource, context.targetId), id: resource.id, kind: "bundle", + linkPath: resolveLinkPath(normalizeMount(resource.mount), context.workspacePath), mode: resource.mode, + mount: normalizeMount(resource.mount), sharing: resource.sharing, sha256: resource.sha256, + source: path.resolve(path.dirname(resource.scope.key), resource.source) + } : { backingPath: resolveBackingPath(resource, context.targetId), id: resource.id, kind: "volume", diff --git a/src/manifest/mcpSchemas.ts b/src/manifest/mcpSchemas.ts index 9cb46d79..05580bcc 100644 --- a/src/manifest/mcpSchemas.ts +++ b/src/manifest/mcpSchemas.ts @@ -24,10 +24,12 @@ export const mcpServerSchema = z env: z.record(z.string(), z.string()).optional(), name: z.string().min(1), transport: z.enum(["sse", "stdio", "streamable_http"]), + tools: z.array(z.string().min(1)).max(32).optional(), url: z.string().optional() }) .strict() .superRefine((value, context) => { + if (value.tools !== undefined && (value.tools.length === 0 || new Set(value.tools).size !== value.tools.length)) context.addIssue({ code: z.ZodIssueCode.custom, message: "MCP tools must be a nonempty unique allowlist" }); if (value.name.startsWith("spawnfile.") || value.name.startsWith("mneme-")) { context.addIssue({ code: z.ZodIssueCode.custom, message: "MCP server name is reserved for compiler-owned generated services" }); } diff --git a/src/manifest/renderSpawnfile.test.ts b/src/manifest/renderSpawnfile.test.ts index 55c73fbc..b1ba626d 100644 --- a/src/manifest/renderSpawnfile.test.ts +++ b/src/manifest/renderSpawnfile.test.ts @@ -589,8 +589,10 @@ describe("renderSpawnfile", () => { it("renders Moltnet secret references without reading their values", () => { const previousOperator = process.env.MOLTNET_OPERATOR_TOKEN; const previousPairing = process.env.MOLTNET_PAIRING_TOKEN; + const previousRelay = process.env.MOLTNET_RELAY_TOKEN; process.env.MOLTNET_OPERATOR_TOKEN = "actual-operator-token-must-not-render"; process.env.MOLTNET_PAIRING_TOKEN = "actual-pairing-token-must-not-render"; + process.env.MOLTNET_RELAY_TOKEN = "actual-relay-token-must-not-render"; const source = renderSpawnfile({ kind: "team", lead: "operator", @@ -633,7 +635,11 @@ describe("renderSpawnfile", () => { pairings: [ { id: "pairing_one", - remote_base_url: "https://partner-net.example", + relay: { + room: "secure-team-partner", + token_secret: "MOLTNET_RELAY_TOKEN", + url: "wss://relay.example.com" + }, remote_network_id: "partner_net", remote_network_name: "PartnerNet", token_secret: "MOLTNET_PAIRING_TOKEN" @@ -642,6 +648,7 @@ describe("renderSpawnfile", () => { }, rooms: [ { + federation: ["pairing_one"], id: "research", members: ["operator"] } @@ -654,11 +661,18 @@ describe("renderSpawnfile", () => { else process.env.MOLTNET_OPERATOR_TOKEN = previousOperator; if (previousPairing === undefined) delete process.env.MOLTNET_PAIRING_TOKEN; else process.env.MOLTNET_PAIRING_TOKEN = previousPairing; + if (previousRelay === undefined) delete process.env.MOLTNET_RELAY_TOKEN; + else process.env.MOLTNET_RELAY_TOKEN = previousRelay; expect(source).toContain("secret: MOLTNET_OPERATOR_TOKEN"); expect(source).toContain("token_secret: MOLTNET_PAIRING_TOKEN"); + expect(source).toContain("token_secret: MOLTNET_RELAY_TOKEN"); + expect(source).toContain("url: wss://relay.example.com"); + expect(source).toContain("room: secure-team-partner"); + expect(source).toContain("federation:\n - pairing_one"); expect(source).not.toContain("actual-operator-token-must-not-render"); expect(source).not.toContain("actual-pairing-token-must-not-render"); + expect(source).not.toContain("actual-relay-token-must-not-render"); expect(source).toContain("id: operator"); expect(source).toContain("mode: bearer"); const parsed = manifestSchema.parse(YAML.parse(source)); @@ -669,6 +683,9 @@ describe("renderSpawnfile", () => { .toBe("MOLTNET_OPERATOR_TOKEN"); expect(parsed.networks[0].server.pairings?.[0]?.token_secret) .toBe("MOLTNET_PAIRING_TOKEN"); + expect(parsed.networks[0].server.pairings?.[0]?.relay?.token_secret) + .toBe("MOLTNET_RELAY_TOKEN"); + expect(parsed.networks[0].rooms[0]?.federation).toEqual(["pairing_one"]); }); it("renders rewritten agent manifests with subagents in canonical order", () => { diff --git a/src/manifest/renderSpawnfileNetworks.ts b/src/manifest/renderSpawnfileNetworks.ts index de2b77f4..3f89fbd2 100644 --- a/src/manifest/renderSpawnfileNetworks.ts +++ b/src/manifest/renderSpawnfileNetworks.ts @@ -18,7 +18,8 @@ const orderTeamAuthToken = (token: { const orderTeamAuthPairing = (pairing: { id: string; - remote_base_url: string; + relay?: { room: string; token_secret: string; url: string }; + remote_base_url?: string; remote_network_id: string; remote_network_name: string; token_secret: string; @@ -26,6 +27,16 @@ const orderTeamAuthPairing = (pairing: { withDefinedEntries([ ["id", pairing.id], ["remote_base_url", pairing.remote_base_url], + [ + "relay", + pairing.relay + ? withDefinedEntries([ + ["url", pairing.relay.url], + ["room", pairing.relay.room], + ["token_secret", pairing.relay.token_secret] + ]) + : undefined + ], ["remote_network_id", pairing.remote_network_id], ["remote_network_name", pairing.remote_network_name], ["token_secret", pairing.token_secret] @@ -117,6 +128,7 @@ export const orderTeamNetworks = ( ["name", room.name], ["visibility", room.visibility], ["write_policy", room.write_policy], + ["federation", room.federation], ["members", room.members] ]) ) diff --git a/src/manifest/renderSpawnfileWorkspace.ts b/src/manifest/renderSpawnfileWorkspace.ts index 297994b4..33911e01 100644 --- a/src/manifest/renderSpawnfileWorkspace.ts +++ b/src/manifest/renderSpawnfileWorkspace.ts @@ -34,6 +34,10 @@ const orderWorkspaceResource = (resource: WorkspaceResource): unknown => { ["sharing", resource.sharing] ]); } + if (resource.kind === "bundle") return withDefinedEntries([ + ["id", resource.id], ["kind", resource.kind], ["source", resource.source], + ["sha256", resource.sha256], ["mount", resource.mount], ["mode", resource.mode], ["sharing", resource.sharing] + ]); return withDefinedEntries([ ["id", resource.id], diff --git a/src/manifest/scheduleSchemas.ts b/src/manifest/scheduleSchemas.ts index 7ee4d072..0f11f808 100644 --- a/src/manifest/scheduleSchemas.ts +++ b/src/manifest/scheduleSchemas.ts @@ -1,12 +1,68 @@ import { z } from "zod"; +import { parseEveryScheduleMs } from "../runtime/scheduleUtils.js"; -const schedulePromptSchema = z.string().min(1); -const scheduleTimezoneSchema = z.string().min(1); +const MAX_RUNTIME_STRING_BYTES = 16_384; +const MAX_RUNTIME_STRING_CODEPOINTS = 4_096; +const schedulePromptSchema = z.string().min(1).superRefine((value, context) => { + if (!value.trim()) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "prompt must not be empty" }); + } else if (Buffer.byteLength(value, "utf8") > MAX_RUNTIME_STRING_BYTES || [...value].length > MAX_RUNTIME_STRING_CODEPOINTS) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "prompt exceeds Daimon's schedule string bound" }); + } +}); +const CRON_FIELD_BOUNDS = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]] as const; +const scheduleTimezoneSchema = z.string().min(1).superRefine((value, context) => { + try { new Intl.DateTimeFormat("en-US", { timeZone: value }); } + catch { context.addIssue({ code: z.ZodIssueCode.custom, message: "timezone must be a valid IANA timezone" }); } +}); +const cronSchema = z.string().trim().min(1).superRefine((value, context) => { + const fields = value.split(/\s+/u); + if ([...value].length > MAX_RUNTIME_STRING_CODEPOINTS) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "cron exceeds Daimon's runtime string bound" }); + } + if (fields.length !== 5 || !fields.every((field, index) => validCronField(field, CRON_FIELD_BOUNDS[index]!)) || !cronCalendarPossible(fields)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "cron must contain five supported numeric fields within their bounds" }); + } +}).transform((value) => value.replace(/\s+/gu, " ")); + +const validCronField = (field: string, [minimum, maximum]: readonly [number, number]): boolean => + field.split(",").every((part) => { + const pieces = part.split("/"); + const step = Number(pieces[1] ?? 1); + if (pieces.length > 2 || !pieces[0] || (pieces[1] !== undefined && !/^\d+$/u.test(pieces[1])) || !Number.isSafeInteger(step) || step < 1) return false; + const range = pieces[0]!; + if (range === "*") return true; + const bounds = range.split("-"); + if (bounds.length > 2 || !bounds.every((bound) => /^\d+$/u.test(bound))) return false; + const first = Number(bounds[0]); const last = Number(bounds[1] ?? bounds[0]); + return first >= minimum && last <= maximum && first <= last; + }); +const cronCalendarPossible = (raw: readonly string[]): boolean => { + if (raw.length !== 5) return false; + const fields = raw.map((field, index) => cronValues(field, CRON_FIELD_BOUNDS[index]!)); + for (let year = 2000; year < 2400; year += 1) for (const month of fields[3]!) { + const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); + for (const day of fields[2]!) if (day <= days && fields[4]!.includes(new Date(Date.UTC(year, month - 1, day)).getUTCDay())) return true; + } + return false; +}; +const cronValues = (field: string, [minimum, maximum]: readonly [number, number]): number[] => { + const result = new Set(); + for (const part of field.split(",")) { + const [range, rawStep] = part.split("/"); const step = Number(rawStep ?? 1); + const bounds = range === "*" ? [minimum, maximum] : range!.split("-").map(Number); + for (let value = bounds[0]!; value <= (bounds[1] ?? bounds[0]!); value += step) result.add(value === 7 && maximum === 7 ? 0 : value); + } + return [...result]; +}; +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" }); +}); export const agentScheduleSchema = z.discriminatedUnion("kind", [ z .object({ - cron: z.string().min(1), + cron: cronSchema, kind: z.literal("cron"), prompt: schedulePromptSchema.optional(), timezone: scheduleTimezoneSchema.optional() @@ -14,12 +70,15 @@ export const agentScheduleSchema = z.discriminatedUnion("kind", [ .strict(), z .object({ - every: z.string().min(1), + every: everySchema, kind: z.literal("every"), prompt: schedulePromptSchema.optional(), timezone: scheduleTimezoneSchema.optional() }) - .strict(), + .strict() + .superRefine((value, context) => { + if (value.timezone !== undefined) context.addIssue({ code: z.ZodIssueCode.custom, message: "every schedules cannot declare a timezone" }); + }), z .object({ kind: z.literal("disabled") diff --git a/src/manifest/schemas.test.ts b/src/manifest/schemas.test.ts index 169f8c93..a40eb22e 100644 --- a/src/manifest/schemas.test.ts +++ b/src/manifest/schemas.test.ts @@ -67,6 +67,14 @@ describe("manifestSchema", () => { expect(isAgentManifest(result)).toBe(true); }); + it("accepts a bounded MCP tool allowlist and rejects empty or duplicate lists", () => { + const source = { kind: "agent", environment: { mcp_servers: [{ command: "/bin/tool", name: "lifecycle", transport: "stdio", tools: ["draft", "release"] }] }, name: "agent", runtime: "daimon", spawnfile_version: "0.1" }; + expect(manifestSchema.safeParse(source).success).toBe(true); + for (const tools of [[], ["release", "release"]]) { + expect(manifestSchema.safeParse({ ...source, environment: { mcp_servers: [{ ...source.environment.mcp_servers[0], tools }] } }).success).toBe(false); + } + }); + it("accepts legacy and explicit bearer MCP auth", () => { const result = manifestSchema.parse({ kind: "agent", @@ -798,6 +806,27 @@ describe("manifestSchema", () => { expect(result.success).toBe(false); }); + it.each([ + "60 0 * * *", "0 24 * * *", "0 0 0 * *", "0 0 32 * *", "0 0 * 0 *", "0 0 * 13 *", "0 0 * * 8", + "10-2 * * * *", "*/0 * * * *", "*/-1 * * * *", "L * * * *", "0 0 * * MON" + ])("rejects semantically invalid cron %s", (cron) => { + expect(manifestSchema.safeParse({ kind: "agent", name: "agent", runtime: "openclaw", schedule: { kind: "cron", cron }, spawnfile_version: "0.1" }).success).toBe(false); + }); + + it("aligns Daimon schedule duration, prompt, and possible-calendar bounds", () => { + const parses = (schedule: unknown) => manifestSchema.safeParse({ kind: "agent", name: "agent", runtime: "daimon", schedule, spawnfile_version: "0.1" }).success; + expect(parses({ kind: "every", every: "365d", prompt: "work" })).toBe(true); + expect(parses({ kind: "every", every: "366d", prompt: "work" })).toBe(false); + expect(parses({ kind: "every", every: "1ms", prompt: " \t" })).toBe(false); + expect(parses({ kind: "every", every: "1ms", prompt: "x".repeat(4_097) })).toBe(false); + expect(parses({ kind: "cron", cron: "0 0 31 2 *", timezone: "UTC", prompt: "work" })).toBe(false); + expect(parses({ kind: "cron", cron: `*/${"9".repeat(400)} * * * *`, timezone: "UTC", prompt: "work" })).toBe(false); + expect(parses({ kind: "cron", cron: "*".repeat(4_097), timezone: "UTC", prompt: "work" })).toBe(false); + const normalized = manifestSchema.parse({ kind: "agent", name: "agent", runtime: "daimon", schedule: { kind: "cron", cron: " 0 5 * * * ", timezone: "UTC", prompt: "work" }, spawnfile_version: "0.1" }); + if (normalized.kind !== "agent") throw new Error("expected agent manifest"); + expect(normalized.schedule?.kind === "cron" ? normalized.schedule.cron : undefined).toBe("0 5 * * *"); + }); + it("rejects schedules on team manifests", () => { const result = manifestSchema.safeParse({ kind: "team", @@ -957,6 +986,7 @@ describe("manifestSchema", () => { }, rooms: [ { + federation: ["partner"], id: "workroom", members: ["worker"] } @@ -967,6 +997,28 @@ describe("manifestSchema", () => { }); expect(result.success).toBe(true); + if (result.success && result.data.kind === "team") { + expect(result.data.networks?.[0]?.rooms[0]?.federation).toEqual(["partner"]); + } + }); + + it("rejects invalid moltnet room federation declarations", () => { + const base = { + kind: "team", + members: [{ id: "worker", ref: "./agents/worker" }], + mode: "swarm", + name: "worker-cell", + networks: [{ + id: "team_net", + provider: "moltnet", + rooms: [{ federation: [] as string[], id: "workroom", members: ["worker"] }] + }], + spawnfile_version: "0.1" + }; + + expect(manifestSchema.safeParse(base).success).toBe(false); + base.networks[0]!.rooms[0]!.federation = ["partner", "partner"]; + expect(manifestSchema.safeParse(base).success).toBe(false); }); it("accepts top-level agent memory declarations", () => { @@ -1818,6 +1870,50 @@ describe("manifestSchema", () => { expect(result.success).toBe(true); }); + it("accepts relay pairings and requires exactly one pairing transport", () => { + const pairing = { + id: "partner", + relay: { + room: "partner-room", + token_secret: "PARTNER_RELAY_TOKEN", + url: "wss://relay.example.com" + }, + remote_network_id: "partner_net", + remote_network_name: "PartnerNet", + token_secret: "REMOTE_PARTNER_TOKEN" + }; + const manifest = { + kind: "team", + members: [{ id: "worker", ref: "./agents/worker" }], + mode: "swarm", + name: "worker-cell", + networks: [{ + id: "team_net", + provider: "moltnet", + rooms: [{ federation: ["partner"], id: "workroom", members: ["worker"] }], + server: { + auth: { mode: "open" }, + listen: { bind: "127.0.0.1", port: 8787 }, + mode: "managed", + pairings: [pairing], + store: { kind: "memory" } + } + }], + spawnfile_version: "0.1" + }; + + expect(manifestSchema.safeParse(manifest).success).toBe(true); + expect(manifestSchema.safeParse({ + ...manifest, + networks: [{ ...manifest.networks[0], server: { ...manifest.networks[0]!.server, pairings: [{ ...pairing, remote_base_url: "https://partner.example.com" }] } }] + }).success).toBe(false); + const { relay: _relay, ...withoutTransport } = pairing; + expect(manifestSchema.safeParse({ + ...manifest, + networks: [{ ...manifest.networks[0], server: { ...manifest.networks[0]!.server, pairings: [withoutTransport] } }] + }).success).toBe(false); + }); + it("rejects external moltnet servers with pairings", () => { const result = manifestSchema.safeParse({ kind: "team", @@ -2550,6 +2646,45 @@ describe("manifestSchema", () => { expect(result.error?.issues[0]?.message).toContain("references unknown member reviewer"); }); + it("accepts scoped remote room members only through the room's pairing", () => { + const manifest = { + kind: "team" as const, + members: [{ id: "writer", ref: "./agents/writer" }], + mode: "swarm" as const, + name: "research-team", + networks: [{ + id: "local_lab", + provider: "moltnet" as const, + rooms: [{ + federation: ["peer-link"], + id: "research", + members: ["writer", "peer-network:remote-agent"] + }], + server: { + auth: { mode: "none" as const }, + listen: { bind: "127.0.0.1", port: 8787 }, + mode: "managed" as const, + pairings: [{ + id: "peer-link", + remote_base_url: "https://sensor.invalid", + remote_network_id: "peer-network", + remote_network_name: "Partner Floor", + token_secret: "PEER_PAIR_TOKEN" + }], + store: { kind: "memory" as const } + } + }], + spawnfile_version: "0.1" as const + }; + + expect(manifestSchema.safeParse(manifest).success).toBe(true); + manifest.networks[0]!.rooms[0]!.federation = ["other-link"]; + const rejected = manifestSchema.safeParse(manifest); + expect(rejected.success).toBe(false); + expect(rejected.error?.issues.some((issue) => + issue.message.includes("references unknown member peer-network:remote-agent"))).toBe(true); + }); + it("accepts per-model auth and endpoint config for custom models", () => { const result = manifestSchema.parse({ execution: { diff --git a/src/manifest/schemas.ts b/src/manifest/schemas.ts index 78180d49..d4a4a5fb 100644 --- a/src/manifest/schemas.ts +++ b/src/manifest/schemas.ts @@ -7,6 +7,7 @@ import { mcpServerSchema } from "./mcpSchemas.js"; import { surfacesSchema } from "./surfaceSchemas.js"; import { externalParticipantServiceSchema } from "./externalParticipantSchemas.js"; import { + isDeclaredPairedRemoteRoomMember, teamNetworkSchema, teamWorkspaceDocsSchema, teamWorkspaceSchema @@ -250,7 +251,9 @@ const teamManifestSchema = commonManifestSchema ); for (const room of network.rooms) { for (const memberId of room.members) { - if (!memberIds.has(memberId) && !externalRoomMembers.has(memberId)) { + if (!memberIds.has(memberId) + && !externalRoomMembers.has(memberId) + && !isDeclaredPairedRemoteRoomMember(network, room, memberId)) { context.addIssue({ code: z.ZodIssueCode.custom, message: `network ${network.id} room ${room.id} references unknown member ${memberId}` @@ -335,6 +338,7 @@ export type { TeamWorkspace, TeamWorkspaceResource } from "./teamNetworkSchemas.js"; +export { isDeclaredPairedRemoteRoomMember } from "./teamNetworkSchemas.js"; export type { DiscordSurface, diff --git a/src/manifest/teamNetworkAccessSchemas.ts b/src/manifest/teamNetworkAccessSchemas.ts index 99d58a4b..d870be09 100644 --- a/src/manifest/teamNetworkAccessSchemas.ts +++ b/src/manifest/teamNetworkAccessSchemas.ts @@ -1,6 +1,17 @@ import { z } from "zod"; export const teamNetworkAgentRegistrationSchema = z.enum(["disabled", "token", "open"]); +export const teamNetworkRoomFederationSchema = z.union([ + z.enum(["none", "all"]), + z.array(z.string().trim().min(1)).min(1).superRefine((value, context) => { + if (new Set(value).size !== value.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "room federation pairing ids must be unique" + }); + } + }) +]); export const teamNetworkRoomVisibilitySchema = z.enum(["public", "private"]); export const teamNetworkRoomWritePolicySchema = z.enum([ "members", diff --git a/src/manifest/teamNetworkSchemas.ts b/src/manifest/teamNetworkSchemas.ts index 187e2db9..57102258 100644 --- a/src/manifest/teamNetworkSchemas.ts +++ b/src/manifest/teamNetworkSchemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + teamNetworkRoomFederationSchema, teamNetworkRoomVisibilitySchema, teamNetworkRoomWritePolicySchema } from "./teamNetworkAccessSchemas.js"; @@ -18,6 +19,7 @@ export type { TeamNetworkServer, TeamNetworkStore } from "./teamNetworkServerSch const teamNetworkRoomSchema = z .object({ + federation: teamNetworkRoomFederationSchema.optional(), id: z.string().trim().min(1), members: z.array(z.string().trim().min(1)), name: z.string().trim().min(1).optional(), @@ -47,3 +49,29 @@ export const teamNetworkSchema = z export type TeamNetwork = z.infer; export type TeamNetworkRoom = z.infer; + +export const isDeclaredPairedRemoteRoomMember = ( + network: TeamNetwork, + room: TeamNetworkRoom, + memberId: string +): boolean => { + const separator = memberId.indexOf(":"); + if (separator <= 0 || separator === memberId.length - 1) { + return false; + } + + const remoteNetworkId = memberId.slice(0, separator).trim(); + const remoteAgentId = memberId.slice(separator + 1).trim(); + if (!remoteNetworkId || !remoteAgentId || network.server?.mode !== "managed") { + return false; + } + + const pairing = network.server.pairings?.find((candidate) => + candidate.remote_network_id === remoteNetworkId); + if (!pairing) { + return false; + } + + return room.federation === "all" + || (Array.isArray(room.federation) && room.federation.includes(pairing.id)); +}; diff --git a/src/manifest/teamNetworkServerSchemas.ts b/src/manifest/teamNetworkServerSchemas.ts index ab2f8657..d0261cfd 100644 --- a/src/manifest/teamNetworkServerSchemas.ts +++ b/src/manifest/teamNetworkServerSchemas.ts @@ -83,15 +83,32 @@ const teamNetworkListenSchema = z }) .strict(); +const teamNetworkPairingRelaySchema = z + .object({ + room: z.string().trim().min(1), + token_secret: z.string().trim().min(1), + url: z.string().trim().min(1) + }) + .strict(); + const teamNetworkPairingSchema = z .object({ id: z.string().trim().min(1), - remote_base_url: z.string().trim().min(1), + relay: teamNetworkPairingRelaySchema.optional(), + remote_base_url: z.string().trim().min(1).optional(), remote_network_id: z.string().trim().min(1), remote_network_name: z.string().trim().min(1), token_secret: z.string().trim().min(1) }) - .strict(); + .strict() + .superRefine((value, context) => { + if ((value.remote_base_url === undefined) === (value.relay === undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "managed Moltnet pairing requires exactly one of remote_base_url or relay" + }); + } + }); const teamNetworkManagedServerSchema = z .object({ diff --git a/src/manifest/workspaceSchemas.ts b/src/manifest/workspaceSchemas.ts index f2c54715..f7f458e8 100644 --- a/src/manifest/workspaceSchemas.ts +++ b/src/manifest/workspaceSchemas.ts @@ -100,8 +100,15 @@ const teamWorkspaceResourceVolumeSchema = z }) .strict(); +const teamWorkspaceResourceBundleSchema = z.object({ + id: z.string().trim().min(1), kind: z.literal("bundle"), mount: resourceMountSchema, + mode: z.literal("readonly"), sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + sharing: z.literal("per_agent").optional(), source: z.string().trim().min(1) +}).strict(); + const teamWorkspaceResourceSchema = z.discriminatedUnion("kind", [ teamWorkspaceResourceGitSchema, + teamWorkspaceResourceBundleSchema, teamWorkspaceResourceVolumeSchema ]); @@ -146,6 +153,10 @@ export const teamWorkspaceSchema = z url: resource.url }); } + if (resource.kind === "bundle") return JSON.stringify({ + kind: resource.kind, mode: resource.mode, mount: normalizeMount(resource.mount), + sha256: resource.sha256, sharing: resource.sharing ?? "per_agent", source: resource.source + }); return JSON.stringify({ kind: "volume", diff --git a/src/runtime/daimon/scheduleAuthority.test.ts b/src/runtime/daimon/scheduleAuthority.test.ts new file mode 100644 index 00000000..83ce0030 --- /dev/null +++ b/src/runtime/daimon/scheduleAuthority.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +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 { createPiTestNode } from "../pi/testHelpers.js"; +import { daimonAdapter } from "./adapter.js"; +import { DAIMON_CONFIG_FILE } from "./config.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./contractManifest.js"; + +const roots: string[] = []; +const digest = (character: string): string => `sha256:${character.repeat(64)}`; +const identity = async (manifestSha256: string = DAIMON_CONTRACT_MANIFEST_SHA256): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-schedule-authority-")); roots.push(root); + const file = path.join(root, "identity.json"); + await writeFile(file, `${JSON.stringify({ + capability_receipt_sha256: digest("a"), development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, + image_architecture: "amd64", image_config_digest: digest("b"), image_manifest_digest: digest("c"), + image_reference: `${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")}`, manifest_sha256: manifestSha256, + registry_authority: "127.0.0.1:54321", version: "spawnfile.local-daimon-runtime-identity.v3" + })}\n`); + return file; +}; +const scheduledTarget = async (kind: "every" | "disabled" = "every") => { + const node = createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: kind === "every" ? { kind, every: "1m", prompt: "work" } : { kind } }); + const compiled = await daimonAdapter.compileAgent(node); + return await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: "agent:scheduled", kind: "agent", slug: "scheduled", value: node }]); +}; + +afterEach(async () => { delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +describe("Daimon schedule image authority", () => { + it("fails closed for the pinned v1 image while v1 unscheduled compilation remains valid", async () => { + await expect(daimonAdapter.compileAgent(createPiTestNode({ + runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every" } + }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + ]) }); + await expect(scheduledTarget()).rejects.toThrow(/does not attest organization runtime v2/u); + const node = createPiTestNode({ runtime: { name: "daimon", options: {} } }); + const compiled = await daimonAdapter.compileAgent(node); + const targets = await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: "agent:v1", kind: "agent", slug: "v1", value: node }]); + expect(JSON.parse(targets[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v1"); + }); + + it("supports active and disabled v2 states only with the canonical local receipt authority", async () => { + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = await identity(); + for (const kind of ["every", "disabled"] as const) { + const node = createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: kind === "every" ? { kind, every: "1m", prompt: "work" } : { kind } }); + const compiled = await daimonAdapter.compileAgent(node); + expect(compiled.capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ + key: "agent.schedule", + message: expect.stringContaining(kind === "disabled" ? "state: disabled" : "state: supported"), + outcome: "supported" + }) + ])); + const targets = await scheduledTarget(kind); + expect(JSON.parse(targets[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v2"); + } + 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); + }); +}); diff --git a/src/runtime/daimon/scheduleAuthority.ts b/src/runtime/daimon/scheduleAuthority.ts new file mode 100644 index 00000000..4976f905 --- /dev/null +++ b/src/runtime/daimon/scheduleAuthority.ts @@ -0,0 +1,22 @@ +import { SpawnfileError } from "../../shared/index.js"; +import { DAIMON_LOCAL_RUNTIME_IDENTITY_ENV, loadLocalDaimonRuntimeIdentity } from "../localDaimonAuthority.js"; + +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./contractManifest.js"; + +export const hasDaimonScheduleAuthority = async (): Promise => { + const identityPath = process.env[DAIMON_LOCAL_RUNTIME_IDENTITY_ENV]?.trim(); + if (!identityPath) return false; + return (await loadLocalDaimonRuntimeIdentity(identityPath)).manifestSha256 === + DAIMON_CONTRACT_MANIFEST_SHA256; +}; + +/** Schedule lowering is allowed only when the selected image receipt binds v2. */ +export const assertDaimonScheduleAuthority = async (): Promise => { + // The checked-in v0.2.0 production pin attests v1 only. + if (!await hasDaimonScheduleAuthority()) { + throw new SpawnfileError( + "runtime_error", + "Daimon schedules are disabled: the selected image capability receipt does not attest organization runtime v2" + ); + } +}; diff --git a/src/runtime/scheduleUtils.ts b/src/runtime/scheduleUtils.ts index 909006e7..8151bbd7 100644 --- a/src/runtime/scheduleUtils.ts +++ b/src/runtime/scheduleUtils.ts @@ -1,6 +1,8 @@ export const isEveryScheduleValue = (value: string): boolean => /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/u.test(value.trim()); +export const MAX_SCHEDULE_INTERVAL_MS = 31_536_000_000; + export const parseEveryScheduleMs = (value: string): number | null => { const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/u.exec(value.trim()); if (!match) { @@ -15,5 +17,8 @@ export const parseEveryScheduleMs = (value: string): number | null => { s: 1000 }; - return Math.max(1, Math.round(Number(match[1]) * multipliers[match[2] ?? "ms"])); + const milliseconds = Math.round(Number(match[1]) * multipliers[match[2] ?? "ms"]); + return Number.isSafeInteger(milliseconds) && milliseconds >= 1 && milliseconds <= MAX_SCHEDULE_INTERVAL_MS + ? milliseconds + : null; }; From 56bf60a4da6932faba1c1a25dda43e78bef1bd20 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:27 +0200 Subject: [PATCH 05/34] feat(runtime): consume autonomous daimon contracts --- .gitignore | 1 + runtimes.yaml | 1 + src/compiler/localMoltnetAuthority.test.ts | 32 +++++ src/compiler/localMoltnetAuthority.ts | 42 ++++-- src/compiler/publicDaimonHost.test.ts | 71 ++++----- src/report/types.ts | 15 +- src/runtime/AGENTS.md | 9 ++ src/runtime/container.test.ts | 85 +++++++---- src/runtime/container.ts | 53 ++++--- src/runtime/daimon/AGENTS.md | 15 +- src/runtime/daimon/adapter.test.ts | 151 +++++++++++++++++--- src/runtime/daimon/adapter.ts | 47 +++++- src/runtime/daimon/config.ts | 95 ++++++++++-- src/runtime/daimon/contract-manifest.json | 2 +- src/runtime/daimon/contract-manifest.sha256 | 2 +- src/runtime/daimon/contractManifest.test.ts | 49 ++++++- src/runtime/daimon/contractManifest.ts | 102 +++++++++++-- src/runtime/daimon/runAuth.test.ts | 114 ++++++++++++--- src/runtime/daimon/runAuth.ts | 116 ++++++++++----- src/runtime/index.ts | 1 + src/runtime/install.test.ts | 1 + src/runtime/install.ts | 2 + src/runtime/localDaimonAuthority.test.ts | 135 +++++++++++++++++ src/runtime/localDaimonAuthority.ts | 99 +++++++++++++ src/runtime/registry.ts | 5 + src/runtime/types.ts | 1 + 26 files changed, 1046 insertions(+), 200 deletions(-) create mode 100644 src/compiler/localMoltnetAuthority.test.ts create mode 100644 src/runtime/localDaimonAuthority.test.ts create mode 100644 src/runtime/localDaimonAuthority.ts diff --git a/.gitignore b/.gitignore index 86e981be..810fe823 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules .env .moltnet .worktree-bootstrap.json +.local-daimon-runtime-identity.json diff --git a/runtimes.yaml b/runtimes.yaml index 4236c517..4dbec9e1 100644 --- a/runtimes.yaml +++ b/runtimes.yaml @@ -21,6 +21,7 @@ runtimes: tag: 0.2.0 digest: sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa capability_receipt: sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7 + contract_manifest_sha256: sha256:95ef6c04f1a757b8cd33498207239aa242d0dd6783308530eadb660285b5f83b status: active openclaw: diff --git a/src/compiler/localMoltnetAuthority.test.ts b/src/compiler/localMoltnetAuthority.test.ts new file mode 100644 index 00000000..be6ae40f --- /dev/null +++ b/src/compiler/localMoltnetAuthority.test.ts @@ -0,0 +1,32 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createLocalMoltnetBridgeProbeConfig, parseLocalReleaseStamp } from "./localMoltnetAuthority.js"; + +describe("local Moltnet bridge capability probes", () => { + it("represents every required Daimon runtime field with private-state-compatible paths", () => { + const directory = "/tmp/spawnfile-moltnet-probe"; + const config = JSON.parse(createLocalMoltnetBridgeProbeConfig("daimon", directory)); + + expect(config.attachments[0].runtime).toEqual({ + control_url: "http://127.0.0.1:9", + kind: "daimon", + receipt_store_path: path.join(directory, "daimon-receipts", "daimon-capability-probe.json"), + token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" + }); + expect(path.isAbsolute(config.attachments[0].runtime.receipt_store_path)).toBe(true); + }); + + it("does not add Daimon-only state to the Pi probe", () => { + const config = JSON.parse(createLocalMoltnetBridgeProbeConfig("pi", "/tmp/probe")); + expect(config.attachments[0].runtime).not.toHaveProperty("receipt_store_path"); + }); + + it("binds archive-mode source, dependency, and pinned toolchain identities", () => { + const digest = `sha256:${"a".repeat(64)}`; + const stamp = { arch: "amd64", asset: "moltnet_linux_amd64.tar.gz", capabilities: ["daimon-bridge", "pi-bridge"], development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, sha256: "b".repeat(64), source_inputs: { dependencies_sha256: digest, mode: "source-bundle", source_sha256: digest, toolchain: "golang:1.24-bookworm@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac" }, source_sha256: digest, stamp_version: "spawnfile.local-moltnet-release-stamp.v1" }; + expect(parseLocalReleaseStamp(JSON.stringify(stamp), "amd64").source_inputs).toEqual(stamp.source_inputs); + expect(() => parseLocalReleaseStamp(JSON.stringify({ ...stamp, source_inputs: { ...stamp.source_inputs, dependencies_sha256: "sha256:bad" } }), "amd64")).toThrow(/complete development-only/u); + }); +}); diff --git a/src/compiler/localMoltnetAuthority.ts b/src/compiler/localMoltnetAuthority.ts index 61bfbf39..93a18244 100644 --- a/src/compiler/localMoltnetAuthority.ts +++ b/src/compiler/localMoltnetAuthority.ts @@ -23,6 +23,7 @@ export interface LocalMoltnetReleaseIdentity { unpublished: true; }>; readonly source_sha256: `sha256:${string}`; + readonly source_inputs?: Readonly<{ dependencies_sha256: `sha256:${string}`; mode: "source-bundle"; source_sha256: `sha256:${string}`; toolchain: string }>; readonly version: "spawnfile.moltnet-release-identity.v1"; } @@ -33,6 +34,7 @@ interface LocalMoltnetReleaseStamp { readonly development: LocalMoltnetReleaseIdentity["development"]; readonly sha256: string; readonly source_sha256: `sha256:${string}`; + readonly source_inputs?: LocalMoltnetReleaseIdentity["source_inputs"]; readonly stamp_version: "spawnfile.local-moltnet-release-stamp.v1"; } @@ -42,13 +44,18 @@ const exactKeys = (value: Record, keys: readonly string[]): boo const assetName = (architecture: MoltnetTargetArchitecture): string => `moltnet_linux_${architecture}.tar.gz`; -const bridgeProbeConfig = (kind: "daimon" | "pi"): string => JSON.stringify({ +/** @internal Complete synthetic bridge contract used by local artifact verification. */ +export const createLocalMoltnetBridgeProbeConfig = ( + kind: "daimon" | "pi", + directory: string +): string => JSON.stringify({ attachments: [{ agent: { id: `${kind}-capability-probe`, name: `${kind} capability probe` }, runtime: kind === "daimon" ? { control_url: "http://127.0.0.1:9", kind, + receipt_store_path: path.join(directory, "daimon-receipts", "daimon-capability-probe.json"), token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" } : { control_url: "http://127.0.0.1:9/agents/pi-capability-probe/wake", kind } @@ -63,7 +70,10 @@ const assertBridgeCapability = async ( kind: "daimon" | "pi" ): Promise => { const configPath = path.join(directory, `${kind}-bridge-probe.json`); - await writeFile(configPath, bridgeProbeConfig(kind), { mode: 0o600 }); + const receiptDirectory = path.join(directory, "daimon-receipts"); + await ensureDirectory(receiptDirectory); + await chmod(receiptDirectory, 0o700); + await writeFile(configPath, createLocalMoltnetBridgeProbeConfig(kind, directory), { mode: 0o600 }); try { await execFile(binaryPath, ["node", configPath], { env: { ...process.env, SPAWNFILE_DAIMON_CONTROL_TOKEN: "capability-probe" }, @@ -83,7 +93,8 @@ const assertBridgeCapability = async ( } }; -const parseLocalReleaseStamp = ( +/** @internal Strict parser shared with provenance regression tests. */ +export const parseLocalReleaseStamp = ( raw: string, architecture: MoltnetTargetArchitecture ): LocalMoltnetReleaseStamp => { @@ -98,7 +109,8 @@ const parseLocalReleaseStamp = ( } const value = parsed as Record; const development = value.development as Record | undefined; - if (!exactKeys(value, ["arch", "asset", "capabilities", "development", "sha256", "source_sha256", "stamp_version"]) + const sourceInputs = value.source_inputs as Record | undefined; + if (!exactKeys(value, ["arch", "asset", "capabilities", "development", "sha256", "source_sha256", "stamp_version", ...(sourceInputs ? ["source_inputs"] : [])]) || value.stamp_version !== "spawnfile.local-moltnet-release-stamp.v1" || value.arch !== architecture || value.asset !== assetName(architecture) @@ -113,7 +125,8 @@ const parseLocalReleaseStamp = ( || typeof value.sha256 !== "string" || !SHA256.test(value.sha256) || typeof value.source_sha256 !== "string" - || !/^sha256:[a-f0-9]{64}$/u.test(value.source_sha256)) { + || !/^sha256:[a-f0-9]{64}$/u.test(value.source_sha256) + || (sourceInputs && (!exactKeys(sourceInputs, ["dependencies_sha256", "mode", "source_sha256", "toolchain"]) || sourceInputs.mode !== "source-bundle" || !/^sha256:[a-f0-9]{64}$/u.test(String(sourceInputs.dependencies_sha256)) || sourceInputs.source_sha256 !== value.source_sha256 || sourceInputs.toolchain !== "golang:1.24-bookworm@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac"))) { throw new SpawnfileError("compile_error", "Local Moltnet release stamp must be a complete development-only dual-bridge identity"); } return value as unknown as LocalMoltnetReleaseStamp; @@ -124,9 +137,6 @@ const verifyBuiltMoltnetArchive = async ( architecture: MoltnetTargetArchitecture, hostArchitecture: MoltnetTargetArchitecture ): Promise => { - if (architecture !== hostArchitecture) { - throw new SpawnfileError("compile_error", "Local Moltnet archive architecture cannot be verified on this host"); - } const temporaryDirectory = path.join(path.dirname(releaseAssetPath), `.spawnfile-moltnet-verify-${process.pid}-${Date.now()}`); try { await ensureDirectory(temporaryDirectory); @@ -136,12 +146,17 @@ const verifyBuiltMoltnetArchive = async ( throw new SpawnfileError("compile_error", "Local Moltnet archive does not contain its moltnet binary"); } await chmod(binaryPath, 0o755); - const { stdout } = await execFile(binaryPath, ["version"]); - if (!stdout.trim()) { - throw new SpawnfileError("compile_error", "Local Moltnet binary did not produce a bounded version identity"); + if (architecture === hostArchitecture) { + const { stdout } = await execFile(binaryPath, ["version"]); if (!stdout.trim()) throw new SpawnfileError("compile_error", "Local Moltnet binary did not produce a bounded version identity"); + await assertBridgeCapability(binaryPath, temporaryDirectory, "pi"); await assertBridgeCapability(binaryPath, temporaryDirectory, "daimon"); + } else { + for (const kind of ["pi", "daimon"] as const) { + const configPath = path.join(temporaryDirectory, `${kind}-docker-probe.json`); await writeFile(configPath, createLocalMoltnetBridgeProbeConfig(kind, "/receipts")); + const { stdout: rawId } = await execFile("docker", ["create", "--platform", `linux/${architecture}`, "--env", "SPAWNFILE_DAIMON_CONTROL_TOKEN=probe", "node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df", "timeout", "2", "/moltnet", "node", "/config.json"]); const id = rawId.trim(); + try { await execFile("docker", ["cp", binaryPath, `${id}:/moltnet`]); await execFile("docker", ["cp", configPath, `${id}:/config.json`]); try { await execFile("docker", ["start", "--attach", id]); } catch (error) { const result = error as { code?: unknown; stdout?: unknown; stderr?: unknown }; const output = `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`; if (result.code !== 124 && !/connection refused|connect:|dial tcp|network is unreachable/iu.test(output)) throw new SpawnfileError("compile_error", `Local Moltnet cross-host ${kind} capability probe failed`); } } + finally { await execFile("docker", ["rm", "--force", id]); } + } } - await assertBridgeCapability(binaryPath, temporaryDirectory, "pi"); - await assertBridgeCapability(binaryPath, temporaryDirectory, "daimon"); } finally { await rm(temporaryDirectory, { force: true, recursive: true }); } @@ -171,6 +186,7 @@ export const readLocalMoltnetReleaseIdentity = async ( capabilities: Object.freeze(["daimon-bridge", "pi-bridge"] as const), development: Object.freeze({ mode: "local-development", non_production: true, unsigned: true, unpublished: true }), source_sha256: stamp.source_sha256, + ...(stamp.source_inputs ? { source_inputs: Object.freeze(stamp.source_inputs) } : {}), version: "spawnfile.moltnet-release-identity.v1" }); }; diff --git a/src/compiler/publicDaimonHost.test.ts b/src/compiler/publicDaimonHost.test.ts index 39dbc427..3a010f51 100644 --- a/src/compiler/publicDaimonHost.test.ts +++ b/src/compiler/publicDaimonHost.test.ts @@ -4,7 +4,9 @@ import { mkdtemp } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; -import { readUtf8File, removeDirectory } from "../filesystem/index.js"; +import { readUtf8File, removeDirectory, writeUtf8File } from "../filesystem/index.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "../runtime/daimon/contractManifest.js"; +const LOCAL_DAIMON_IMAGE_REPOSITORY = "127.0.0.1:54321/noopolis/spawnfile-runtime-daimon"; import { compileProject } from "./compileProject.js"; @@ -12,50 +14,51 @@ const temporaryDirectories: string[] = []; const fixture = path.resolve(process.cwd(), "examples", "daimon-public-host"); afterEach(async () => { + delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); }); describe("public Daimon host fixture", () => { - it("emits one strict public host config, launcher, and pinned generic image receipt check", async () => { + it("rejects the pinned prior runtime before compiling a new v3 public host", async () => { const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-public-daimon-")); temporaryDirectories.push(outputDirectory); + await expect(compileProject(fixture, { outputDirectory })).rejects.toThrow(/exact contract manifest/u); + }); - const result = await compileProject(fixture, { outputDirectory }); - const container = result.report.container; - const instance = container?.runtime_instances.find((candidate) => candidate.runtime === "daimon"); - const configPath = path.join( - outputDirectory, - "container/rootfs/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json" - ); - const launcherPath = path.join( - outputDirectory, - "container/rootfs/opt/spawnfile/runtime-installs/daimon/daimon-start.sh" - ); - - expect(container?.runtimes_installed).toEqual(["daimon"]); - expect(instance).toMatchObject({ - config_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json", - engine_by_node_id: { "agent:public-host-agent": "codex" }, - id: "daimon-organization", - model_auth_methods: {}, - model_secrets_required: [], - node_ids: ["agent:public-host-agent"] - }); - expect(container?.moltnet).toBeUndefined(); + it("compiles against an explicit local identity without changing production registry pins", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-public-daimon-local-")); + const outputDirectory = path.join(directory, "output"); + const identityPath = path.join(directory, "identity.json"); + temporaryDirectories.push(directory); + const digest = (character: string): string => `sha256:${character.repeat(64)}`; + await writeUtf8File(identityPath, `${JSON.stringify({ + capability_receipt_sha256: digest("a"), + development: { + mode: "local-development", + non_production: true, + unpublished: true, + unsigned: true + }, + image_architecture: "amd64", + image_config_digest: digest("b"), + image_manifest_digest: digest("c"), + image_reference: `${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" + })}\n`); + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = identityPath; - await expect(readUtf8File(configPath)).resolves.toContain('"version": "noopolis.daimon.organization-runtime.v1"'); - const launcher = await readUtf8File(launcherPath); - expect(launcher).toContain("exec daimon-runtime run --config /var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json"); - expect(launcher).not.toContain(""); + await compileProject(fixture, { outputDirectory }); const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); - expect(dockerfile).toContain("COPY --from=noopolis/spawnfile-runtime-daimon@sha256:"); - expect(dockerfile).toContain("capability-receipt.json"); expect(dockerfile).toContain( - "install -d -o root -g root -m 700 '/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes'" + `COPY --from=${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")} ` ); - expect(dockerfile).toContain('USER root\nENTRYPOINT ["/opt/spawnfile/daimon-uid-entrypoint.sh"]'); - expect(dockerfile).not.toContain("USER spawnfile"); - expect(dockerfile).not.toContain("npm install --omit=dev --no-fund --no-audit @noopolis/daimon"); + expect(dockerfile).toContain(digest("a")); + expect(dockerfile).not.toContain("noopolis/spawnfile-runtime-daimon@sha256:19b671"); + await expect((await import("node:fs/promises")).readFile(path.join(outputDirectory, "spawnfile-report.json"), "utf8").then(JSON.parse)).resolves.toMatchObject({ + container: { local_daimon_runtime: { registry_authority: "127.0.0.1:54321", image_reference: `${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")}` } } + }); }); }); diff --git a/src/report/types.ts b/src/report/types.ts index 7bab07ce..40fefe19 100644 --- a/src/report/types.ts +++ b/src/report/types.ts @@ -40,15 +40,23 @@ export interface ContainerRuntimeInstanceReport { export interface ContainerWorkspaceResourceReport { backing_path: string; id: string; - kind: "git" | "volume"; + kind: "bundle" | "git" | "volume"; link_path: string; mode: "mutable" | "readonly"; mount: string; + mount_path: string; + replacement_sentinel?: { + path: string; + result: "verified_on_startup"; + }; + resolved_identity: string; sharing: "per_agent" | "team"; + volume_name: string | null; } export interface ContainerPersistentMountReport { id: string; + lifecycle?: "exclusive-reattach"; mount_path: string; reason: string; volume_name: string; @@ -255,6 +263,11 @@ export interface ContainerReport { entrypoint: string; env_example: string; internal_ports?: number[]; + local_daimon_runtime?: { + capability_receipt_sha256: string; + image_reference: string; + registry_authority: string; + }; model_secrets_required: string[]; moltnet?: ContainerMoltnetPlanSummary; memory?: ContainerMemoryReport[]; diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index da91f82e..61ee9a68 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -13,6 +13,7 @@ src/runtime/ ├── mnemeMcp.ts # Shared Mneme MCP lowering used by MCP-capable runtimes ├── container.ts # Container install recipes (createRuntimeInstallRecipe) per bundled runtime ├── containerPackageOverrides.ts # Runtime install npm package override contract consumed by container.ts +├── localDaimonAuthority.ts # Exact non-production identity-file parser for loopback Daimon images ├── registry.ts # Bundled adapter registration and lookup ├── scheduleUtils.ts # Shared duration schedule helpers for runtime lowering ├── daimon/ # Public Daimon organization-host adapter @@ -30,6 +31,14 @@ Adapter-specific behavior belongs in the runtime subfolders. That includes runti `containerPackageOverrides.ts` defines the `RuntimeContainerPackageOverrides` contract (`packageName -> { filename }`) used by the legacy Pi recipe. The Phase-A Daimon host never installs a local package: it copies a separately released generic image by immutable digest and verifies that image's capability receipt. The actual `npm pack`-into-build-context step is compiler-level I/O and lives in `src/compiler/containerPackageOverrides.ts`; this folder only owns recipe shaping, never packing or engine installation. +`localDaimonAuthority.ts` is the only local Daimon image seam. It accepts only +an attested `127.0.0.1:` registry authority and is activated +only by an absolute path in `SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY`, accepts +the exact v2 non-production schema, and requires the fixed loopback registry +repository by OCI manifest digest plus a capability-receipt SHA-256. Raw image +or receipt environment overrides fail closed. With no identity path, +`container.ts` uses the checked-in `runtimes.yaml` digest and receipt unchanged. + `types.ts`'s `ContainerTarget` carries an optional `engineByNodeId?: Record` passthrough slot for adapters that disclose a native engine kind per compiled node. The Pi adapter reports generated Pi engine kinds; the Daimon adapter reports its public `codex`/`grok`/`agy` engine intents. `src/compiler/containerArtifactsPlans.ts`/`containerArtifacts.ts` thread the map unchanged into `ContainerRuntimeInstanceReport.engine_by_node_id` (`src/report/types.ts`). Other adapters omit it. `MNEME_RECALL_MODE` (mneme's own `MNEME_RECALL_MODE_ENV`, see `ecosystem/mneme/src/runtime/recallMode.ts`) is the B70 memory recall-mode ablation knob (`on`/`off`/`shuffled`). It follows the same trust shape as `NOOPOLIS_RUN_ID` and `MNEME_OLLAMA_BASE_URL`: a harness/container-injected environment variable, read directly inside mneme's `JsonlMemoryRuntime` constructor, never a config field this package lowers, never a model-facing tool argument, and never model-writable. That is intentional — Daimon and the Pi prelude (`appPreludeSource.ts`'s `createMemoryRuntimeOptions`) pass no `recallMode` field, so a generated container that never sets the env var always resolves to `on`, and this folder needed zero changes to add the ablation knob. Only an operator's own process env, or `src/runtime/pi/appCliSource.test.ts`'s "wired to the real @noopolis/mneme memory runtime" case (which sets it directly, in-process, per mode) may set it. diff --git a/src/runtime/container.test.ts b/src/runtime/container.test.ts index 625fb0e1..b22759b4 100644 --- a/src/runtime/container.test.ts +++ b/src/runtime/container.test.ts @@ -1,17 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it } from "vitest"; import { NOOPOLIS_RUN_ID_ENV } from "./common.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"; + +const localIdentityDirectories: string[] = []; +const testDigest = (character: string): string => `sha256:${character.repeat(64)}`; + +const createLocalDaimonIdentity = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-container-daimon-")); + localIdentityDirectories.push(directory); + const identityPath = path.join(directory, "identity.json"); + await writeFile(identityPath, `${JSON.stringify({ + capability_receipt_sha256: testDigest("a"), + development: { + mode: "local-development", + non_production: true, + unpublished: true, + unsigned: true + }, + image_architecture: "amd64", + image_config_digest: testDigest("b"), + image_manifest_digest: testDigest("c"), + image_reference: `${LOCAL_DAIMON_IMAGE_REPOSITORY}@${testDigest("c")}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" + })}\n`); + return identityPath; +}; describe("runtime container install recipes", () => { - afterEach(() => { + afterEach(async () => { delete process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE; delete process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE; delete process.env.SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT; + delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; delete process.env.SPAWNFILE_OPENCLAW_RUNTIME_IMAGE; delete process.env.SPAWNFILE_PI_RUNTIME_BASE_IMAGE; delete process.env.SPAWNFILE_PICOCLAW_RUNTIME_IMAGE; delete process.env.NOOPOLIS_RUN_ID; + await Promise.all(localIdentityDirectories.splice(0).map((directory) => + rm(directory, { force: true, recursive: true }) + )); }); it("creates an OpenClaw image-copy recipe from the pinned runtime image", async () => { @@ -49,22 +85,8 @@ describe("runtime container install recipes", () => { ]); }); - it("creates a Daimon image-copy recipe from the pinned runtime image", async () => { - const recipe = await createRuntimeInstallRecipe("daimon"); - - expect(recipe.runtimeName).toBe("daimon"); - expect(recipe.runtimeRoot).toBe(`${RUNTIME_INSTALL_ROOT}/daimon`); - expect(recipe.commands).toEqual(expect.arrayContaining([ - expect.stringContaining("capability-receipt.json"), - expect.stringContaining('actual="$(sha256sum'), - `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/daimon-runtime /usr/local/bin/daimon-runtime`, - `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/codex /usr/local/bin/codex`, - `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/grok /usr/local/bin/grok`, - `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/agy /usr/local/bin/agy` - ])); - expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` - ]); + it("rejects the pinned prior Daimon image when the compiler requires the new v3 manifest", async () => { + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/exact contract manifest/u); }); it("installs overridden runtime packages from the vendored tarball path in the Pi recipe", async () => { @@ -118,26 +140,33 @@ describe("runtime container install recipes", () => { expect(recipe.commands).toEqual([`mkdir -p ${RUNTIME_INSTALL_ROOT}/pi`]); }); - it("allows only an exact Daimon runtime image and receipt override", async () => { - process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa"; - process.env.SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT = "sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7"; + it("consumes an explicit local Daimon identity by immutable manifest and exact receipt digest", async () => { + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = await createLocalDaimonIdentity(); const recipe = await createRuntimeInstallRecipe("daimon"); expect(recipe.baseImage).toBeUndefined(); expect(recipe.commands).toEqual(expect.arrayContaining([ expect.stringContaining("capability-receipt.json"), + expect.stringContaining(testDigest("a")), `ln -sf ${RUNTIME_INSTALL_ROOT}/daimon/bin/daimon-runtime /usr/local/bin/daimon-runtime` ])); expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` + `COPY --from=${LOCAL_DAIMON_IMAGE_REPOSITORY}@${testDigest("c")} ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` ]); }); - it("rejects mutable Daimon runtime image overrides", async () => { + it("rejects raw Daimon image overrides instead of treating them as local authority", async () => { process.env.SPAWNFILE_DAIMON_RUNTIME_IMAGE = "noopolis/spawnfile-runtime-daimon:test"; await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow( - "source and tag-only overrides are disabled" + "Raw Daimon runtime image overrides are disabled" + ); + }); + + it("rejects a detached raw Daimon receipt override", async () => { + process.env.SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT = testDigest("a"); + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow( + "Raw Daimon runtime image overrides are disabled" ); }); @@ -167,15 +196,13 @@ describe("runtime container install recipes", () => { ]); }); - it("ignores the legacy Daimon base-image override", async () => { + it("ignores the legacy Daimon base-image override without bypassing the manifest gate", async () => { process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE = "noopolis/spawnfile-runtime-daimon:legacy"; - await expect(createRuntimeInstallRecipe("daimon")).resolves.toMatchObject({ - copyCommands: [expect.stringContaining("@sha256:")] - }); + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/exact contract manifest/u); }); it("omits NOOPOLIS_RUN_ID from every recipe's env when unset", async () => { - for (const runtimeName of ["openclaw", "picoclaw", "daimon", "pi"] as const) { + for (const runtimeName of ["openclaw", "picoclaw", "pi"] as const) { const recipe = await createRuntimeInstallRecipe(runtimeName); expect(recipe.env).toEqual({}); } @@ -184,7 +211,7 @@ describe("runtime container install recipes", () => { it("stamps the same NOOPOLIS_RUN_ID into every recipe's env when set", async () => { process.env.NOOPOLIS_RUN_ID = "run-shared-1"; - for (const runtimeName of ["openclaw", "picoclaw", "daimon", "pi"] as const) { + for (const runtimeName of ["openclaw", "picoclaw", "pi"] as const) { const recipe = await createRuntimeInstallRecipe(runtimeName); expect(recipe.env).toEqual({ [NOOPOLIS_RUN_ID_ENV]: "run-shared-1" }); } diff --git a/src/runtime/container.ts b/src/runtime/container.ts index 34d742e3..4c46dae3 100644 --- a/src/runtime/container.ts +++ b/src/runtime/container.ts @@ -8,11 +8,17 @@ import { type RuntimeContainerPackageOverrides } from "./containerPackageOverrides.js"; import { resolveRuntimeInstallSelection } from "./install.js"; +import { + DAIMON_LOCAL_RUNTIME_IDENTITY_ENV, + loadLocalDaimonRuntimeIdentity +} from "./localDaimonAuthority.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; export const RUNTIME_INSTALL_ROOT = "/opt/spawnfile/runtime-installs"; const PI_RUNTIME_BASE_IMAGE_ENV = "SPAWNFILE_PI_RUNTIME_BASE_IMAGE"; -const DAIMON_RUNTIME_IMAGE_ENV = "SPAWNFILE_DAIMON_RUNTIME_IMAGE"; -const DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV = "SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT"; +const LEGACY_DAIMON_RUNTIME_IMAGE_ENV = "SPAWNFILE_DAIMON_RUNTIME_IMAGE"; +const LEGACY_DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV = + "SPAWNFILE_DAIMON_RUNTIME_CAPABILITY_RECEIPT"; const DAIMON_CAPABILITY_RECEIPT_FILE = "capability-receipt.json"; const OPENCLAW_RUNTIME_IMAGE_ENV = "SPAWNFILE_OPENCLAW_RUNTIME_IMAGE"; const PICOCLAW_RUNTIME_IMAGE_ENV = "SPAWNFILE_PICOCLAW_RUNTIME_IMAGE"; @@ -100,13 +106,14 @@ const resolveRuntimeImageRef = ( /** * Daimon is distributed only as a generic, source-free runtime image. A - * development override is deliberately narrow: it can repeat the exact - * immutable image and receipt selected by the registry, never substitute a - * checkout, mutable tag, or a host-installed CLI. + * local-development authority is deliberately narrow: it must be an explicit + * generated identity file naming the approved loopback registry repository by + * manifest digest plus the exact embedded receipt digest. Raw image/receipt + * overrides, mutable tags, checkouts, and host-installed CLIs are rejected. */ -const resolveDaimonRuntimeImageRef = ( +const resolveDaimonRuntimeImageRef = async ( selection: Awaited> -): { capabilityReceipt: string; image: string } => { +): Promise<{ capabilityReceipt: string; image: string }> => { if ( selection.kind !== "container_image" || !selection.digest || @@ -119,20 +126,28 @@ const resolveDaimonRuntimeImageRef = ( } const pinnedImage = `${selection.image}@${selection.digest}`; - const override = process.env[DAIMON_RUNTIME_IMAGE_ENV]?.trim(); - if (!override) { - return { capabilityReceipt: selection.capabilityReceipt, image: pinnedImage }; + if ( + process.env[LEGACY_DAIMON_RUNTIME_IMAGE_ENV]?.trim() || + process.env[LEGACY_DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV]?.trim() + ) { + throw new SpawnfileError( + "runtime_error", + `Raw Daimon runtime image overrides are disabled; use ${DAIMON_LOCAL_RUNTIME_IDENTITY_ENV}` + ); } - const receipt = process.env[DAIMON_RUNTIME_CAPABILITY_RECEIPT_ENV]?.trim(); - if (override !== pinnedImage || receipt !== selection.capabilityReceipt) { + const identityPath = process.env[DAIMON_LOCAL_RUNTIME_IDENTITY_ENV]?.trim(); + if (identityPath) { + const identity = await loadLocalDaimonRuntimeIdentity(identityPath); + return { capabilityReceipt: identity.capabilityReceipt, image: identity.imageReference }; + } + if (selection.contractManifestSha256 !== DAIMON_CONTRACT_MANIFEST_SHA256) { throw new SpawnfileError( "runtime_error", - "Daimon runtime image overrides must exactly match the pinned image digest and capability receipt; source and tag-only overrides are disabled" + "Selected Daimon runtime image does not attest the compiler's exact contract manifest; build and select a matching local artifact or pin a published compatible release" ); } - - return { capabilityReceipt: selection.capabilityReceipt, image: override }; + return { capabilityReceipt: selection.capabilityReceipt, image: pinnedImage }; }; export interface RuntimeInstallRecipeOptions { @@ -218,14 +233,16 @@ export const createRuntimeInstallRecipe = async ( }; } case "daimon": { - const daimonRuntime = resolveDaimonRuntimeImageRef(selection); + const daimonRuntime = await resolveDaimonRuntimeImageRef(selection); return { commands: [ `test -f ${installRoot}/${DAIMON_CAPABILITY_RECEIPT_FILE} && actual="$(sha256sum ${installRoot}/${DAIMON_CAPABILITY_RECEIPT_FILE} | awk '{print "sha256:" $1}')" && test "$actual" = ${JSON.stringify(daimonRuntime.capabilityReceipt)}`, + `test -f ${installRoot}/contract-manifest.json && test -f ${installRoot}/contract-manifest.sha256 && manifest="$(cat ${installRoot}/contract-manifest.sha256)" && test "$manifest" = ${JSON.stringify(DAIMON_CONTRACT_MANIFEST_SHA256)} && test "$(sha256sum ${installRoot}/contract-manifest.json | awk '{print "sha256:" $1}')" = "$manifest" && node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(r.manifest_sha256!==process.argv[2])process.exit(1)' ${installRoot}/${DAIMON_CAPABILITY_RECEIPT_FILE} "$manifest"`, `ln -sf ${installRoot}/bin/daimon-runtime /usr/local/bin/daimon-runtime`, `ln -sf ${installRoot}/bin/codex /usr/local/bin/codex`, - `ln -sf ${installRoot}/bin/grok /usr/local/bin/grok`, - `ln -sf ${installRoot}/bin/agy /usr/local/bin/agy` + `install -o root -g root -m 0555 ${installRoot}/bin/grok /usr/local/bin/grok`, + `ln -sf ${installRoot}/bin/agy /usr/local/bin/agy`, + `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, diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 230486ce..6b00897d 100644 --- a/src/runtime/daimon/AGENTS.md +++ b/src/runtime/daimon/AGENTS.md @@ -12,6 +12,15 @@ application per agent. It permits only compiler-owned Moltnet public-wake attachments; Daimon consumes a generic 0700 private ingress itself. `runtime: pi` remains the legacy generated Pi path. -The consumed Daimon manifest may declare the AGY host realm's stable volume -target and opaque unlock slot. This adapter renders those resources but never -starts D-Bus, runs AGY, or reads either secret. +The consumed Daimon manifest declares stable AGY and Grok host-realm volumes +plus their opaque bootstrap slots. This adapter renders those resources but +never starts a provider CLI, D-Bus, or a turn. + +The consumed manifest also pins the native Grok broker source/x64/arm64 +digests, fixed root/org/broker/worker identities, root-only registrations, +and loopback-only provider/MCP endpoints. Container provisioning must match +that authority exactly and must not publish either broker port. + +Codex keeps an isolated per-agent credential home. Grok keeps isolated +per-agent non-auth state but one durable rotating subscription credential +realm; never fan out Grok refresh authority across writable homes. diff --git a/src/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts index ad6ad418..676a47ab 100644 --- a/src/runtime/daimon/adapter.test.ts +++ b/src/runtime/daimon/adapter.test.ts @@ -16,7 +16,8 @@ import { DAIMON_CONFIG_FILE } from "./config.js"; const createDaimonNode = (id: string, name = id, engine = "codex") => { const node = createPiTestNode({ name, - runtime: { name: "daimon", options: { engine } } + runtime: { name: "daimon", options: { engine } }, + source: `/tmp/agent/${id}/Spawnfile` }); if (engine === "codex") return node; const { model: _model, ...execution } = node.execution!; @@ -87,7 +88,7 @@ describe("daimonAdapter", () => { expect(entrypoint).toContain("'bash' '/opt/spawnfile/runtime-installs/daimon/daimon-start.sh'"); expect(entrypoint).not.toContain("SPAWNFILE_CLI_AUTH_JSON"); expect(daimonAdapter.container.systemDeps).toEqual([ - "bash", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux" + "bash", "bubblewrap", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux" ]); }); @@ -112,17 +113,19 @@ describe("daimonAdapter", () => { recipeEnv: {}, runtimeName: "daimon", runtimeRoot: "/opt/spawnfile/runtime-installs/daimon", sourceIds: target.sourceIds, targetFiles: target.files }; - const compilePlan = { nodes: [] } as unknown as CompilePlan; + const compilePlan = { nodes: agents.map((agent) => ({ + id: agent.id, kind: "agent", runtimeName: "daimon", slug: agent.slug, value: agent.node + })) } as unknown as CompilePlan; const attachments = agents.map((agent) => JSON.parse(createMoltnetNodeConfigContent({ agentNode: agent.node, - attachment: { memberId: agent.id, network: "local", teamSource: null }, + attachment: { memberId: agent.slug, network: "local", teamSource: null }, networkServer: { auth: { mode: "none" }, mode: "external", url: "http://127.0.0.1:9999" }, nodeSlug: agent.slug, plan: compilePlan, serverPlan: { baseUrl: "http://127.0.0.1:9999", rooms: [] } }).content)); const entrypoint = renderEntrypoint([runtimePlan], [], { - moltnet: { nodePlans: [{ configPath: "/config/moltnet.json", networkId: "local" }] as any, serverPlans: [] } + moltnet: { nodePlans: [{ configPath: "/config/moltnet.json", networkId: "local", receiptStorePath: "/var/lib/spawnfile/moltnet/networks/local/daimon-receipts/codex.json" }] as any, serverPlans: [] } }); expect(JSON.parse(target.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).agents) @@ -132,9 +135,46 @@ describe("daimonAdapter", () => { expect.objectContaining({ engine: { kind: "agy" } }) ])); expect(target.opaqueMountTargets).toEqual([ - "/var/lib/spawnfile/daimon/agy-unlock-secret" + "/var/lib/spawnfile/daimon/agy-unlock-secret", + "/var/lib/spawnfile/daimon/grok-bootstrap-auth" ]); expect(target.persistentMounts).toEqual([ + { + id: "daimon-engine-home-codex-codex", + mountPath: "/runtime-homes/codex/.codex", + reason: "Daimon codex subscription credential home for agent:codex" + }, + { + id: "daimon-engine-home-grok-grok", + mountPath: "/runtime-homes/grok/.grok", + reason: "Daimon grok subscription credential home for agent:grok" + }, + { + id: "daimon-tool-state-agy", + mountPath: "/runtime-homes/agy/tool-state", + reason: "Daimon durable cognition tool receipts for agent:agy" + }, + { + id: "daimon-tool-state-codex", + mountPath: "/runtime-homes/codex/tool-state", + reason: "Daimon durable cognition tool receipts for agent:codex" + }, + { + id: "daimon-tool-state-grok", + mountPath: "/runtime-homes/grok/tool-state", + reason: "Daimon durable cognition tool receipts for agent:grok" + }, + { + id: "daimon-organization-acceptance-store", + mountPath: "/state/wake-acceptance", + reason: "Daimon organization durable wake acceptance store" + }, + { + id: "daimon-grok-subscription-realm", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + reason: "Daimon host Grok subscription credential realm" + }, { id: "daimon-agy-subscription-realm", mountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", @@ -147,6 +187,9 @@ describe("daimonAdapter", () => { } ]); const start = target.files.find((file) => file.path === "runtime/daimon-start.sh")!; + expect(start.content).toContain( + 'export DAIMON_RUNTIME_ACCEPTANCE_STORE="/state/wake-acceptance"' + ); expect(start.content.indexOf("/runtime-homes/agy")).toBeLessThan( start.content.indexOf('if [ "$#" -gt 0 ]; then exec daimon-runtime "$@"; fi') ); @@ -155,27 +198,66 @@ describe("daimonAdapter", () => { ); for (const [index, agent] of agents.entries()) { expect(attachments[index].attachments[0].runtime).toEqual(resolveRuntimeConfig( - compilePlan, agent.node, agent.slug, "local", agent.id + compilePlan, agent.node, agent.slug, "local", agent.slug )); expect(attachments[index].attachments[0].runtime).toMatchObject({ + agent_id: agent.id, control_url: "http://127.0.0.1:19700", kind: "daimon", + receipt_store_path: `/var/lib/spawnfile/moltnet/networks/local/daimon-receipts/${agent.slug}.json`, token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" }); + expect(attachments[index].attachments[0].agent.id).toBe(agent.slug); } expect(entrypoint.indexOf("/healthz")).toBeGreaterThan(entrypoint.indexOf("daimon-start.sh")); expect(entrypoint.indexOf("moltnet node")).toBeGreaterThan(entrypoint.indexOf("/healthz")); + expect(entrypoint).toContain("install -d -m 700 '/var/lib/spawnfile/moltnet/networks/local/daimon-receipts'"); expect(entrypoint).not.toContain("Authorization: Bearer"); expect(entrypoint).not.toMatch(/(?:codex|grok|agy) (?:exec|run)/u); }); - it("does not emit AGY state when every agent uses a portable engine", async () => { + it("emits only isolated portable credential homes when no agent uses AGY", async () => { const target = (await daimonAdapter.createContainerTargets!([ { emittedFiles: [], id: "agent:codex", kind: "agent", slug: "codex", value: createDaimonNode("codex") }, { emittedFiles: [], id: "agent:grok", kind: "agent", slug: "grok", value: createDaimonNode("grok", "Grok", "grok") } ]))[0]!; - expect(target.opaqueMountTargets).toBeUndefined(); - expect(target.persistentMounts).toBeUndefined(); + expect(target.opaqueMountTargets).toEqual(["/var/lib/spawnfile/daimon/grok-bootstrap-auth"]); + expect(target.persistentMounts).toEqual([ + { + id: "daimon-engine-home-codex-codex", + mountPath: "/runtime-homes/codex/.codex", + reason: "Daimon codex subscription credential home for agent:codex" + }, + { + id: "daimon-engine-home-grok-grok", + mountPath: "/runtime-homes/grok/.grok", + reason: "Daimon grok subscription credential home for agent:grok" + }, + { + id: "daimon-tool-state-codex", + mountPath: "/runtime-homes/codex/tool-state", + reason: "Daimon durable cognition tool receipts for agent:codex" + }, + { + id: "daimon-tool-state-grok", + mountPath: "/runtime-homes/grok/tool-state", + reason: "Daimon durable cognition tool receipts for agent:grok" + }, + { + id: "daimon-organization-acceptance-store", + mountPath: "/state/wake-acceptance", + reason: "Daimon organization durable wake acceptance store" + }, + { + id: "daimon-grok-subscription-realm", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + reason: "Daimon host Grok subscription credential realm" + } + ]); + expect(target.persistentMounts?.some((mount) => + mount.mountPath.includes(".daimon-inbound") + )).toBe(false); }); it("rejects 33 agents before emitting a partial target", async () => { @@ -195,21 +277,24 @@ describe("daimonAdapter", () => { ); }); - it("selects a source-free immutable runtime image", async () => { - const recipe = await createRuntimeInstallRecipe("daimon"); - expect(recipe.copyCommands).toEqual([ - expect.stringContaining("noopolis/spawnfile-runtime-daimon@sha256:") - ]); - expect(recipe.commands.join("\n")).toContain("daimon-runtime"); - expect(recipe.commands.join("\n")).not.toContain("npm install"); + it("rejects the pinned image until it attests the exact compiler contract", async () => { + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/exact contract manifest/u); }); - it("fails closed for schedules, MCP, and non-Moltnet Daimon surface behavior", async () => { + it("lowers schedules while retaining the MCP and non-Moltnet surface boundary", async () => { await expect(daimonAdapter.compileAgent(createDaimonNode("schedule", "Schedule"))).resolves.toBeDefined(); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every", prompt: "work" } - }))).rejects.toThrow("does not lower schedules yet"); + }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + ]) }); + await expect(daimonAdapter.compileAgent(createPiTestNode({ + runtime: { name: "daimon", options: {} }, + schedule: { kind: "disabled" } + }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + ]) }); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: { engine: "grok" } } }))).rejects.toThrow("must omit Spawnfile execution.model"); @@ -229,7 +314,7 @@ describe("daimonAdapter", () => { await expect(daimonAdapter.compileAgent({ ...createDaimonNode("mcp"), mcpServers: [{ name: "unsupported" }] - } as any)).rejects.toThrow(/does not lower MCP/u); + } as any)).rejects.toThrow(/explicit tools allowlist/u); await expect(daimonAdapter.createContainerTargets!([])).resolves.toEqual([]); expect(daimonAdapter.validateRuntimeOptions?.({ engine: 7 } as any)).toEqual([ @@ -239,6 +324,32 @@ describe("daimonAdapter", () => { .toEqual([expect.objectContaining({ message: expect.stringContaining("unexpected") })]); }); + it("lowers declared production MCP and scoped Moltnet cognition capabilities", async () => { + const node = { ...createDaimonNode("tools"), mcpServers: [{ name: "lifecycle", transport: "stdio", command: "/opt/tools/lifecycle", args: ["serve"], tools: ["checkpoint"], env: {} }], surfaces: { moltnet: [{ network: "news", rooms: { desk: { wake: "all" } }, dms: { enabled: false } }] } } as any; + const compiled = await daimonAdapter.compileAgent(node); + const target = (await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: "agent:tools", kind: "agent", slug: "tools", value: node }]))[0]!; + const agent = JSON.parse(target.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).agents[0]; + expect(agent.mcp).toEqual([{ name: "lifecycle", transport: "stdio", command: "/opt/tools/lifecycle", args: ["serve"], env: {}, tools: ["checkpoint"] }]); + expect(agent.moltnet).toEqual({ cliPath: "/usr/local/bin/moltnet", configPath: "/agents/tools/.moltnet/config.json", networks: [{ id: "news", rooms: ["desk"], dms: false }] }); + expect(compiled.capabilities).toEqual(expect.arrayContaining([expect.objectContaining({ key: "mcp.lifecycle", outcome: "supported" }), expect.objectContaining({ key: "surfaces.moltnet", outcome: "supported" })])); + + const remote = { ...createDaimonNode("remote"), mcpServers: [{ name: "search", transport: "streamable_http", url: "https://mcp.example/tools", auth: { mode: "bearer", secret: "MCP_TOKEN" }, tools: ["query"] }], surfaces: { moltnet: [{ network: "private", dms: { enabled: true } }] } } as any; + const remoteCompiled = await daimonAdapter.compileAgent(remote); + const remoteTarget = (await daimonAdapter.createContainerTargets!([{ emittedFiles: remoteCompiled.files, id: "agent:remote", kind: "agent", slug: "remote", value: remote }]))[0]!; + const remoteAgent = JSON.parse(remoteTarget.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).agents[0]; + expect(remoteAgent.mcp).toEqual([{ name: "search", transport: "streamable_http", url: "https://mcp.example/tools", authSecretEnv: "MCP_TOKEN", args: [], env: {}, tools: ["query"] }]); + expect(remoteAgent.moltnet.networks).toEqual([{ id: "private", rooms: [], dms: true }]); + }); + + it("fails closed for unsafe or unavailable production cognition authorities", async () => { + const base = createDaimonNode("unsafe"); + await expect(daimonAdapter.compileAgent({ ...base, mcpServers: [{ name: "missing", transport: "stdio", command: "/bin/tool" }] } as any)).rejects.toThrow(/tools allowlist/u); + await expect(daimonAdapter.compileAgent({ ...base, mcpServers: [{ name: "relative", transport: "stdio", command: "tool", tools: ["act"] }] } as any)).rejects.toThrow(/absolute command/u); + const agy = createDaimonNode("agy-tools", "agy-tools", "agy"); + await expect(daimonAdapter.compileAgent({ ...agy, mcpServers: [{ name: "tool", transport: "stdio", command: "/bin/tool", tools: ["act"] }] } as any)).rejects.toThrow(/AGY does not expose/u); + await expect(daimonAdapter.compileAgent({ ...agy, surfaces: { moltnet: [{ network: "news" }] } } as any)).rejects.toThrow(/AGY does not expose/u); + }); + it("preserves non-workspace files and rejects invalid engines and oversized instructions", async () => { const node = createDaimonNode("files"); const target = (await daimonAdapter.createContainerTargets!([{ diff --git a/src/runtime/daimon/adapter.ts b/src/runtime/daimon/adapter.ts index d6771d02..62c15221 100644 --- a/src/runtime/daimon/adapter.ts +++ b/src/runtime/daimon/adapter.ts @@ -1,6 +1,8 @@ import type { EffectiveModelTarget, ResolvedAgentNode, ResolvedAgentSurfaces } from "../../compiler/types.js"; +import type { CapabilityReport } from "../../report/index.js"; import { SpawnfileError } from "../../shared/index.js"; import { createAgentCapabilities, createDiagnostic, createDocumentFiles, createSkillFiles } from "../common.js"; +import { parseEveryScheduleMs } from "../scheduleUtils.js"; import type { AdapterCompileResult, RuntimeAdapter } from "../types.js"; import { @@ -11,6 +13,7 @@ import { resolveDaimonEngine } from "./config.js"; import { prepareDaimonRuntimeAuth } from "./runAuth.js"; +import { hasDaimonScheduleAuthority } from "./scheduleAuthority.js"; const assertDaimonSurfaces = (surfaces: ResolvedAgentSurfaces | undefined): void => { if (!surfaces) return; @@ -35,11 +38,10 @@ const assertDaimonModel = (target: EffectiveModelTarget): void => { }; const unsupportedAgentFeatures = (node: ResolvedAgentNode): void => { - if (node.mcpServers.length > 0) { - throw new SpawnfileError("validation_error", "Daimon organization runtime v1 does not lower MCP declarations yet"); - } - if (node.schedule && node.schedule.kind !== "disabled") { - throw new SpawnfileError("validation_error", "Daimon organization runtime v1 does not lower schedules yet"); + if (resolveDaimonEngine(node) === "agy" && (node.mcpServers.length > 0 || (node.surfaces?.moltnet?.length ?? 0) > 0)) throw new SpawnfileError("validation_error", "Daimon AGY does not expose cognition tools; use Codex or Grok for declared MCP or Moltnet actions"); + for (const server of node.mcpServers) { + if (!server.tools?.length) throw new SpawnfileError("validation_error", `Daimon MCP server ${server.name} requires an explicit tools allowlist`); + if (server.transport === "stdio" && !server.command?.startsWith("/")) throw new SpawnfileError("validation_error", `Daimon stdio MCP server ${server.name} requires an absolute command`); } if (resolveDaimonEngine(node) !== "codex" && node.execution?.model) { throw new SpawnfileError( @@ -49,6 +51,33 @@ const unsupportedAgentFeatures = (node: ResolvedAgentNode): void => { } }; +const scheduleCapabilityFor = async ( + node: ResolvedAgentNode +): Promise<{ message?: string; outcome?: CapabilityReport["outcome"] }> => { + if (!node.schedule) return {}; + let authoritative = false; + try { authoritative = await hasDaimonScheduleAuthority(); } catch { /* The lowering gate reports invalid receipt details. */ } + if (!authoritative) { + return { + message: "Daimon v2 schedule state: degraded; the selected image receipt does not attest v2, so no schedule lowering is emitted", + outcome: "degraded" + }; + } + if (node.schedule.kind === "disabled") { + return { + message: "Daimon v2 schedule state: disabled; normalized=disabled; persistence=none; timer=stopped", + outcome: "supported" + }; + } + const normalized = node.schedule.kind === "every" + ? `every/${parseEveryScheduleMs(node.schedule.every)}ms` + : `cron/${node.schedule.cron.trim().replace(/\s+/gu, " ")}; zone=${node.schedule.timezone ?? "UTC"}`; + return { + message: `Daimon v2 schedule state: supported; normalized=${normalized}; persistence=sha256(agent+schedule) in durable acceptance root; timer=runtime-managed`, + outcome: "supported" + }; +}; + export const daimonAdapter: RuntimeAdapter = { assertSupportedModelTarget: assertDaimonModel, assertSupportedSurfaces: assertDaimonSurfaces, @@ -72,6 +101,7 @@ export const daimonAdapter: RuntimeAdapter = { startCommand: ["bash", "/daimon-start.sh"], systemDeps: [ "bash", + "bubblewrap", "ca-certificates", "curl", "dbus-daemon", @@ -81,11 +111,16 @@ export const daimonAdapter: RuntimeAdapter = { }, async compileAgent(node): Promise { unsupportedAgentFeatures(node); + const scheduleCapability = await scheduleCapabilityFor(node); return { capabilities: createAgentCapabilities(node, { + mcpOutcome: "supported", + moltnetMessage: "Daimon exposes one scoped authenticated send tool during real cognition turns", + moltnetOutcome: "supported", memoryMessage: "Daimon organization runtime v1 does not lower Spawnfile memory declarations yet", memoryOutcome: "degraded", - scheduleOutcome: node.schedule ? "degraded" : undefined + scheduleMessage: scheduleCapability.message, + scheduleOutcome: scheduleCapability.outcome }), diagnostics: node.execution?.sandbox ? [createDiagnostic("warn", "Daimon runtime isolation is enforced by the selected runtime image")] diff --git a/src/runtime/daimon/config.ts b/src/runtime/daimon/config.ts index 3140edfa..41a4cf55 100644 --- a/src/runtime/daimon/config.ts +++ b/src/runtime/daimon/config.ts @@ -1,18 +1,25 @@ import path from "node:path"; import type { ResolvedAgentNode } from "../../compiler/types.js"; +import { parseEveryScheduleMs } from "../scheduleUtils.js"; import { SpawnfileError } from "../../shared/index.js"; import type { ContainerTarget, ContainerTargetInput, EmittedFile } from "../types.js"; import { DAIMON_AGY_SUBSCRIPTION_REALM, - DAIMON_ENGINE_CREDENTIALS + DAIMON_ENGINE_CREDENTIALS, + DAIMON_GROK_SUBSCRIPTION_REALM } from "./contractManifest.js"; +import { assertDaimonScheduleAuthority } from "./scheduleAuthority.js"; export const DAIMON_CONFIG_FILE = "daimon-organization-runtime.json"; export const DAIMON_CONTROL_PORT = 19700; export const DAIMON_MAX_AGENTS = 32; export const DAIMON_ORGANIZATION_TARGET_ID = "daimon-organization"; +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_RUNTIME_HOMES_DIRECTORY = "runtime-homes"; const DAIMON_MAX_CONFIG_BYTES = 1_048_576; const DAIMON_MAX_INSTRUCTION_BYTES = 16_384; @@ -20,6 +27,22 @@ const DAIMON_MAX_INSTRUCTION_CODEPOINTS = 4_096; export const DAIMON_ENGINES = ["agy", "codex", "grok"] as const; type DaimonEngine = typeof DAIMON_ENGINES[number]; +const normalizeSchedule = (node: ResolvedAgentNode): Record | undefined => { + const schedule = node.schedule; + if (!schedule || schedule.kind === "disabled") return schedule ? { kind: "disabled" } : undefined; + 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 { + cron: schedule.cron.trim().replace(/\s+/gu, " "), + kind: "cron", + prompt: schedule.prompt ?? "Scheduled work", + timezone: schedule.timezone ?? "UTC" + }; +}; + const formatInstructions = (node: ResolvedAgentNode): string => node.docs.map((document) => `# ${document.role}\n\n${document.content}`).join("\n\n").trim() || `You are ${node.name}. Follow the workspace instructions.`; @@ -57,10 +80,12 @@ const renderStartScript = (agents: Array<{ runtimeHomePath: string; workspacePath: string; }>): string => { + const acceptanceStorePath = `/${DAIMON_RUNTIME_ACCEPTANCE_STORE_DIRECTORY}`; + const readinessReceiptPath = `${acceptanceStorePath}/runtime-readiness.json`; const setup = agents.flatMap((agent) => { - const credential = agent.engine.kind === "agy" - ? undefined - : DAIMON_ENGINE_CREDENTIALS[agent.engine.kind]; + const credential = agent.engine.kind === "codex" + ? DAIMON_ENGINE_CREDENTIALS.codex + : undefined; const inbound = path.posix.join(agent.runtimeHomePath, ".daimon-inbound"); return [ `install -d -m 700 ${[ @@ -76,6 +101,10 @@ const renderStartScript = (agents: Array<{ return [ "#!/usr/bin/env bash", "set -euo pipefail", + `install -d -m 700 ${JSON.stringify(acceptanceStorePath)}`, + `export ${DAIMON_RUNTIME_ACCEPTANCE_STORE_ENV}=${JSON.stringify(acceptanceStorePath)}`, + `rm -f ${JSON.stringify(readinessReceiptPath)}`, + `export ${DAIMON_RUNTIME_READINESS_RECEIPT_ENV}=${JSON.stringify(readinessReceiptPath)}`, ...setup, 'if [ "$#" -gt 0 ]; then exec daimon-runtime "$@"; fi', "exec daimon-runtime run --config " @@ -97,18 +126,32 @@ export const createDaimonContainerTargets = async ( ); } + const hasSchedules = agents.some((input) => input.value.schedule !== undefined); + if (hasSchedules) await assertDaimonScheduleAuthority(); const configAgents = agents .map((input) => ({ engine: { kind: resolveDaimonEngine(input.value) }, id: input.id, instructions: formatInstructions(input.value), name: input.value.name, + ...(input.value.mcpServers.length === 0 ? {} : { mcp: input.value.mcpServers.map((server) => ({ + name: server.name, transport: server.transport, args: server.args ?? [], env: server.env ?? {}, tools: server.tools!, + ...(server.command ? { command: server.command } : {}), ...(server.url ? { url: server.url } : {}), + ...(server.auth?.mode === "bearer" ? { authSecretEnv: server.auth.secret } : {}) + })) }), + ...(input.value.surfaces?.moltnet?.length ? { moltnet: { + cliPath: "/usr/local/bin/moltnet", + configPath: `/agents/${input.slug}/.moltnet/config.json`, + networks: input.value.surfaces.moltnet.map((attachment) => ({ id: attachment.network, rooms: Object.keys(attachment.rooms ?? {}).sort(), dms: attachment.dms?.enabled === true })) + } } : {}), runtimeHomePath: `/${DAIMON_RUNTIME_HOMES_DIRECTORY}/${input.slug}`, - workspacePath: `/agents/${input.slug}` + workspacePath: `/agents/${input.slug}`, + ...(hasSchedules ? { schedule: normalizeSchedule(input.value) ?? { kind: "disabled" } } : {}) })) .sort((left, right) => left.id.localeCompare(right.id)); const engineByNodeId = Object.fromEntries(configAgents.map((agent) => [agent.id, agent.engine.kind])); const hasAgy = configAgents.some((agent) => agent.engine.kind === "agy"); + const hasGrok = configAgents.some((agent) => agent.engine.kind === "grok"); const agyRuntimeHomeMounts = configAgents .filter((agent) => agent.engine.kind === "agy") .map((agent) => ({ @@ -116,6 +159,22 @@ export const createDaimonContainerTargets = async ( mountPath: agent.runtimeHomePath, reason: `Daimon AGY subscription runtime home for ${agent.id}` })); + const portableEngineHomeMounts = configAgents + .filter((agent) => agent.engine.kind !== "agy") + .map((agent) => { + const destinationRelativePath = agent.engine.kind === "grok" + ? DAIMON_GROK_SUBSCRIPTION_REALM.agentCredentialRelativePath + : DAIMON_ENGINE_CREDENTIALS.codex.destinationRelativePath; + return { + id: `daimon-engine-home-${agent.engine.kind}-${path.posix.basename(agent.runtimeHomePath)}`, + mountPath: path.posix.join( + agent.runtimeHomePath, + path.posix.dirname(destinationRelativePath) + ), + reason: `Daimon ${agent.engine.kind} subscription credential home for ${agent.id}` + }; + }); + const toolStateMounts = configAgents.map((agent) => ({ id: `daimon-tool-state-${path.posix.basename(agent.runtimeHomePath)}`, mountPath: path.posix.join(agent.runtimeHomePath, "tool-state"), reason: `Daimon durable cognition tool receipts for ${agent.id}` })); for (const agent of configAgents) assertPublicInstructionBounds(agent.id, agent.instructions); const config = { agents: configAgents, @@ -124,7 +183,9 @@ export const createDaimonContainerTargets = async ( controlTokenEnv: "SPAWNFILE_DAIMON_CONTROL_TOKEN", port: DAIMON_CONTROL_PORT }, - version: "noopolis.daimon.organization-runtime.v1" + version: hasSchedules + ? "noopolis.daimon.organization-runtime.v2" + : "noopolis.daimon.organization-runtime.v1" }; const serializedConfig = `${JSON.stringify(config, null, 2)}\n`; if (Buffer.byteLength(serializedConfig, "utf8") > DAIMON_MAX_CONFIG_BYTES) { @@ -143,14 +204,26 @@ export const createDaimonContainerTargets = async ( } ], id: DAIMON_ORGANIZATION_TARGET_ID, - ...(hasAgy ? { - opaqueMountTargets: [DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath], - persistentMounts: [{ + ...(hasAgy || hasGrok ? { + opaqueMountTargets: [ + ...(hasAgy ? [DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath] : []), + ...(hasGrok ? [DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapMountPath] : []) + ], + } : {}), + persistentMounts: [...portableEngineHomeMounts, ...toolStateMounts, { + id: DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID, + mountPath: `/${DAIMON_RUNTIME_ACCEPTANCE_STORE_DIRECTORY}`, + reason: "Daimon organization durable wake acceptance store" + }, ...(hasGrok ? [{ + id: "daimon-grok-subscription-realm", + lifecycle: "exclusive-reattach" as const, + mountPath: DAIMON_GROK_SUBSCRIPTION_REALM.durableMountPath, + reason: "Daimon host Grok subscription credential realm" + }] : []), ...(hasAgy ? [{ id: "daimon-agy-subscription-realm", mountPath: DAIMON_AGY_SUBSCRIPTION_REALM.durableMountPath, reason: "Daimon host AGY subscription realm" - }, ...agyRuntimeHomeMounts] - } : {}), + }, ...agyRuntimeHomeMounts] : [])], sourceIds: agents.map((agent) => agent.id).sort() }]; }; diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json index f12052ac..677d1eac 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"},"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"],"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"},"grok":{"destinationRelativePath":".grok/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/grok-auth","sourceSlot":"grok-auth"}},"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"},"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"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v1","wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","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":{"maximum":180000,"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[].mcp","agents[].moltnet"],"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"},"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"},"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"}]}} diff --git a/src/runtime/daimon/contract-manifest.sha256 b/src/runtime/daimon/contract-manifest.sha256 index 5e3a1572..f7b01bde 100644 --- a/src/runtime/daimon/contract-manifest.sha256 +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -1 +1 @@ -d31ebbda8b720fa1c20b3cfc11aec1bfc04ae4b95e4becf2f76f681f150a60d8 +sha256:65b21675dcc5a76395d345c4111a8abdeb36b43bcc5fd71292957a1e30fb5e5d diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts index 9a8652fd..d4d1dabe 100644 --- a/src/runtime/daimon/contractManifest.test.ts +++ b/src/runtime/daimon/contractManifest.test.ts @@ -11,6 +11,7 @@ import { DAIMON_CONTRACT_MANIFEST_DIGEST_FILE, DAIMON_CONTRACT_MANIFEST_FILE, DAIMON_CONTRACT_MANIFEST_VERSION, + DAIMON_GROK_ENGINE_BROKER, parseDaimonContractManifest, readVerifiedDaimonContractManifest } from "./contractManifest.js"; @@ -31,16 +32,36 @@ const manifest = () => ({ unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret", unlockSourceSlot: "agy-unlock-secret" }, + grokSubscriptionRealm: { + agentCredentialRelativePath: ".grok/auth.json", + bootstrapMountPath: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", + bootstrapSourceSlot: "grok-auth", + directoryMode: 0o700, + durableMountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + fileMode: 0o600, + maxCredentialBytes: 65_536 + }, + grokEngineBroker: DAIMON_GROK_ENGINE_BROKER, consumedConfigFields: [ "version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", - "agents[].runtimeHomePath", "agents[].engine.kind" + "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" ], engineCredentialMaterial: { codex: { destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" }, - grok: { destinationRelativePath: ".grok/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/grok-auth", sourceSlot: "grok-auth" } }, supportedEngineKinds: ["agy", "codex", "grok"], + organizationRuntimeConfigV2Schema: { + $id: "noopolis.daimon.organization-runtime.v2", + properties: { agents: { items: { properties: { schedule: { oneOf: [{}, {}, {}] } } } } } + }, + wakeAcceptanceTypes: ["manual", "message", "schedule", "external"], + deliverySemantics: { + activeDeliveryIdempotency: "unbounded-until-terminal", terminalReceiptHorizon: 2_048, + recovery: "at-least-once-with-stable-wake-id", concurrentSameAgentTurns: false, externalEffectsExactlyOnce: false + }, version: DAIMON_CONTRACT_MANIFEST_VERSION }); @@ -68,7 +89,17 @@ describe("Daimon contract manifest", () => { durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", unlockMountPath: "/var/lib/spawnfile/daimon/agy-unlock-secret" }, + grokSubscriptionRealm: { + durableMountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + bootstrapMountPath: "/var/lib/spawnfile/daimon/grok-bootstrap-auth" + }, + grokEngineBroker: { + identities: { organizationUid: 2_000, brokerUid: 2_100, firstWorkerUid: 2_200 }, + providerProxy: { host: "127.0.0.1", port: 43_123 }, + mcpFacade: { host: "127.0.0.1", port: 43_124, path: "/mcp" } + }, supportedEngineKinds: ["agy", "codex", "grok"] + , wakeAcceptanceTypes: ["manual", "message", "schedule", "external"] } }); }); @@ -92,7 +123,7 @@ describe("Daimon contract manifest", () => { expect(() => assertDaimonRuntimeHome("relative/runtime-home")).toThrow(/absolute POSIX/u); }); - it("rejects malformed credential and AGY material at the consumed contract boundary", () => { + it("rejects malformed credential and subscription-realm material at the consumed contract boundary", () => { for (const invalid of [null, [], "manifest"]) { expect(() => parseDaimonContractManifest(invalid)).toThrow(/manifest/u); } @@ -104,6 +135,18 @@ describe("Daimon contract manifest", () => { ...manifest(), agySubscriptionRealm: { ...manifest().agySubscriptionRealm, directoryMode: 0o755 } })).toThrow(/AGY subscription realm/u); + expect(() => parseDaimonContractManifest({ + ...manifest(), + grokSubscriptionRealm: { ...manifest().grokSubscriptionRealm, directoryMode: 0o755 } + })).toThrow(/Grok subscription realm/u); + expect(() => parseDaimonContractManifest({ + ...manifest(), + grokEngineBroker: { ...DAIMON_GROK_ENGINE_BROKER, providerProxy: { host: "0.0.0.0", port: 43_123 } } + })).toThrow(/Grok engine broker/u); + expect(() => parseDaimonContractManifest({ + ...manifest(), + grokEngineBroker: { ...DAIMON_GROK_ENGINE_BROKER, nativeAbiVersion: 1 } + })).toThrow(/Grok engine broker/u); }); it("rejects missing, malformed, noncanonical, and digest-mismatched packaged files", async () => { diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts index 253ff220..76636529 100644 --- a/src/runtime/daimon/contractManifest.ts +++ b/src/runtime/daimon/contractManifest.ts @@ -5,11 +5,14 @@ import path from "node:path"; import { SpawnfileError } from "../../shared/index.js"; export const DAIMON_CONTRACT_MANIFEST_VERSION = - "noopolis.daimon.runtime-contract-manifest.v1" as const; + "noopolis.daimon.runtime-contract-manifest.v3" as const; +export const DAIMON_CONTRACT_MANIFEST_SHA256 = + "sha256:65b21675dcc5a76395d345c4111a8abdeb36b43bcc5fd71292957a1e30fb5e5d" 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"; export const DAIMON_ENGINE_KINDS = ["agy", "codex", "grok"] as const; +export const DAIMON_ORGANIZATION_RUNTIME_CONFIG_VERSIONS = ["noopolis.daimon.organization-runtime.v1", "noopolis.daimon.organization-runtime.v2"] as const; export const DAIMON_ENGINE_CREDENTIALS = { codex: { destinationRelativePath: ".codex/auth.json", @@ -18,12 +21,35 @@ export const DAIMON_ENGINE_CREDENTIALS = { sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" }, - grok: { - destinationRelativePath: ".grok/auth.json", - directoryMode: 0o700, - fileMode: 0o600, - sourceRelativePath: ".daimon-inbound/grok-auth", - sourceSlot: "grok-auth" +} as const; +export const DAIMON_GROK_SUBSCRIPTION_REALM = { + agentCredentialRelativePath: ".grok/auth.json", + bootstrapMountPath: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", + bootstrapSourceSlot: "grok-auth", + directoryMode: 0o700, + durableMountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + fileMode: 0o600, + maxCredentialBytes: 64 * 1024 +} as const; +export const DAIMON_GROK_ENGINE_BROKER = { + nativeAbiVersion: 2, + nativeExecutablePath: "/opt/daimon/bin/daimon-engine-broker", + grokExecutablePath: "/usr/local/bin/grok", + registrationPath: "/etc/daimon-engine-broker/registrations.bin", + credentialHomePath: "/var/lib/spawnfile/daimon/grok-subscription-realm", + turnStorePath: "/var/lib/spawnfile/daimon/grok-subscription-realm/turns", + controlSocketPath: "/run/daimon-engine-broker/control.sock", + backendSocketPath: "/run/daimon-engine-broker/backend.sock", + launcherSocketPath: "/run/daimon-engine-broker/launcher.sock", + serviceConfigPath: "/etc/daimon-engine-broker/service.json", + providerProxy: { host: "127.0.0.1", port: 43_123 }, + mcpFacade: { host: "127.0.0.1", port: 43_124, path: "/mcp" }, + identities: { organizationUid: 2_000, brokerUid: 2_100, firstWorkerUid: 2_200 }, + bounds: { promptBytes: 65_536, capabilityBytes: 4_096, capabilityBundleBytes: 8_196, outputBytes: 65_536 }, + artifacts: { + sourceSha256: "bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58", + x64Sha256: "e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd", + arm64Sha256: "ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d" } } as const; export const DAIMON_AGY_SUBSCRIPTION_REALM = { @@ -43,7 +69,17 @@ export interface DaimonContractManifest { readonly agySubscriptionRealm: typeof DAIMON_AGY_SUBSCRIPTION_REALM; readonly consumedConfigFields: readonly string[]; readonly engineCredentialMaterial: Readonly>; + readonly grokSubscriptionRealm: typeof DAIMON_GROK_SUBSCRIPTION_REALM; + readonly grokEngineBroker: typeof DAIMON_GROK_ENGINE_BROKER; readonly supportedEngineKinds: readonly DaimonEngine[]; + readonly wakeAcceptanceTypes: readonly ["manual", "message", "schedule", "external"]; + readonly deliverySemantics: Readonly<{ + activeDeliveryIdempotency: "unbounded-until-terminal"; + terminalReceiptHorizon: 2_048; + recovery: "at-least-once-with-stable-wake-id"; + concurrentSameAgentTurns: false; + externalEffectsExactlyOnce: false; + }>; readonly version: typeof DAIMON_CONTRACT_MANIFEST_VERSION; } @@ -56,7 +92,10 @@ const SHA256 = /^[a-f0-9]{64}$/u; const expectedConfigFields = [ "version", "host.bindHost", "host.port", "host.controlTokenEnv", "agents[].id", "agents[].name", "agents[].instructions", "agents[].workspacePath", - "agents[].runtimeHomePath", "agents[].engine.kind" + "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" ] as const; const exactKeys = (value: Record, keys: readonly string[]): boolean => Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); @@ -102,6 +141,21 @@ const matchesAgyRealm = (value: unknown): value is typeof DAIMON_AGY_SUBSCRIPTIO .every(([name, expected]) => realm[name] === expected); }; +const matchesGrokRealm = (value: unknown): value is typeof DAIMON_GROK_SUBSCRIPTION_REALM => { + const realm = asRecord(value, "Grok subscription realm"); + return exactKeys(realm, [ + "agentCredentialRelativePath", "bootstrapMountPath", "bootstrapSourceSlot", + "directoryMode", "durableMountPath", "fileMode", "maxCredentialBytes" + ]) && Object.entries(DAIMON_GROK_SUBSCRIPTION_REALM) + .every(([name, expected]) => realm[name] === expected); +}; + +const matchesGrokEngineBroker = (value: unknown): value is typeof DAIMON_GROK_ENGINE_BROKER => { + const broker = asRecord(value, "Grok engine broker"); + if (!exactKeys(broker, Object.keys(DAIMON_GROK_ENGINE_BROKER))) return false; + return canonicalJson(broker) === canonicalJson(DAIMON_GROK_ENGINE_BROKER); +}; + export const parseDaimonContractManifest = (raw: unknown): DaimonContractManifest => { const root = asRecord(raw, "root"); if ( @@ -109,11 +163,24 @@ export const parseDaimonContractManifest = (raw: unknown): DaimonContractManifes !Array.isArray(root.supportedEngineKinds) || root.supportedEngineKinds.join("\0") !== "agy\0codex\0grok" || !Array.isArray(root.consumedConfigFields) || - root.consumedConfigFields.join("\0") !== expectedConfigFields.join("\0") + root.consumedConfigFields.join("\0") !== expectedConfigFields.join("\0") || + !Array.isArray(root.wakeAcceptanceTypes) || + root.wakeAcceptanceTypes.join("\0") !== "manual\0message\0schedule\0external" ) return fail("has an unsupported version or configuration contract"); + const v2 = asRecord(root.organizationRuntimeConfigV2Schema, "organizationRuntimeConfigV2Schema"); + const v2Properties = asRecord(v2.properties, "organizationRuntimeConfigV2Schema.properties"); + const v2Agents = asRecord(v2Properties.agents, "organizationRuntimeConfigV2Schema.properties.agents"); + const v2Agent = asRecord(v2Agents.items, "organizationRuntimeConfigV2Schema.properties.agents.items"); + const v2AgentProperties = asRecord(v2Agent.properties, "organizationRuntimeConfigV2Schema.properties.agents.items.properties"); + const schedule = asRecord(v2AgentProperties.schedule, "organizationRuntimeConfigV2Schema schedule"); + if (v2.$id !== "noopolis.daimon.organization-runtime.v2" || !Array.isArray(schedule.oneOf) || schedule.oneOf.length !== 3) return fail("does not attest the organization runtime v2 schedule contract"); + const semantics = asRecord(root.deliverySemantics, "deliverySemantics"); + if (!exactKeys(semantics, ["activeDeliveryIdempotency", "terminalReceiptHorizon", "recovery", "concurrentSameAgentTurns", "externalEffectsExactlyOnce"]) || + semantics.activeDeliveryIdempotency !== "unbounded-until-terminal" || semantics.terminalReceiptHorizon !== 2_048 || + semantics.recovery !== "at-least-once-with-stable-wake-id" || semantics.concurrentSameAgentTurns !== false || semantics.externalEffectsExactlyOnce !== false) return fail("has unsupported delivery semantics"); const materials = asRecord(root.engineCredentialMaterial, "engineCredentialMaterial"); - if (!exactKeys(materials, ["codex", "grok"])) return fail("has unsupported credential material"); - for (const engine of ["codex", "grok"] as const) { + if (!exactKeys(materials, ["codex"])) return fail("has unsupported credential material"); + for (const engine of ["codex"] as const) { if (!matchesCredentialMaterial(materials[engine], DAIMON_ENGINE_CREDENTIALS[engine])) { return fail(`has unsafe ${engine} credential material`); } @@ -121,11 +188,24 @@ export const parseDaimonContractManifest = (raw: unknown): DaimonContractManifes if (!matchesAgyRealm(root.agySubscriptionRealm)) { return fail("has unsafe AGY subscription realm material"); } + if (!matchesGrokRealm(root.grokSubscriptionRealm)) { + return fail("has unsafe Grok subscription realm material"); + } + if (!matchesGrokEngineBroker(root.grokEngineBroker)) { + return fail("has unsafe Grok engine broker material"); + } return Object.freeze({ agySubscriptionRealm: Object.freeze({ ...DAIMON_AGY_SUBSCRIPTION_REALM }), consumedConfigFields: Object.freeze([...expectedConfigFields]), engineCredentialMaterial: Object.freeze({ ...DAIMON_ENGINE_CREDENTIALS }), + grokSubscriptionRealm: Object.freeze({ ...DAIMON_GROK_SUBSCRIPTION_REALM }), + grokEngineBroker: Object.freeze({ ...DAIMON_GROK_ENGINE_BROKER }), supportedEngineKinds: Object.freeze([...DAIMON_ENGINE_KINDS]), + wakeAcceptanceTypes: Object.freeze(["manual", "message", "schedule", "external"] as const), + deliverySemantics: Object.freeze({ + activeDeliveryIdempotency: "unbounded-until-terminal", terminalReceiptHorizon: 2_048, + recovery: "at-least-once-with-stable-wake-id", concurrentSameAgentTurns: false, externalEffectsExactlyOnce: false + }), version: DAIMON_CONTRACT_MANIFEST_VERSION }); }; diff --git a/src/runtime/daimon/runAuth.test.ts b/src/runtime/daimon/runAuth.test.ts index 58453f3d..e3116207 100644 --- a/src/runtime/daimon/runAuth.test.ts +++ b/src/runtime/daimon/runAuth.test.ts @@ -10,6 +10,8 @@ import { prepareDaimonRuntimeAuth } from "./runAuth.js"; const temporaryDirectories: string[] = []; const originalCodexHome = process.env.CODEX_HOME; const originalGrokHome = process.env.GROK_HOME; +const codexCredential = () => JSON.stringify({ tokens: { access_token: "test-access", refresh_token: "test-refresh" } }); +const grokCredential = (accessLength = 32, refreshLength = 16) => JSON.stringify({ "https://auth.x.ai::test": { key: "a".repeat(accessLength), refresh_token: "r".repeat(refreshLength), expires_at: "2099-01-01T00:00:00.000Z" } }); const createTempDirectory = async (prefix: string): Promise => { const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); temporaryDirectories.push(directory); @@ -63,7 +65,7 @@ describe("prepareDaimonRuntimeAuth", () => { const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); const codexHome = await createTempDirectory("spawnfile-daimon-codex-"); process.env.CODEX_HOME = codexHome; - await writeFile(path.join(codexHome, "auth.json"), "{\"token\":\"redacted\"}\n"); + await writeFile(path.join(codexHome, "auth.json"), codexCredential()); await chmod(path.join(codexHome, "auth.json"), 0o600); const home = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex"; const configPath = await writeConfig(outputDirectory, home); @@ -77,12 +79,33 @@ describe("prepareDaimonRuntimeAuth", () => { const mount = prepared.mountArgs[1]!; const source = path.join(codexHome, "auth.json"); expect(mount).toBe(`${source}:${home}/.daimon-inbound/codex-auth:ro`); - expect(prepared.launchIdentity).toEqual({ kind: "daimon", uid: process.getuid?.() }); + expect(prepared.launchIdentity).toBeUndefined(); expect(prepared.mountArgs.join("\n")).not.toContain(tempRoot); expect((await lstat(path.join(outputDirectory, "container", "rootfs", `.${home}`, ".daimon-inbound"))).mode & 0o777) .toBe(0o700); }); + it("accepts the declared native Codex refresh credential variants", async () => { + for (const nativeCredential of [ + { accessToken: "test-access", refreshToken: "test-refresh" }, + { token: "test-access", refreshToken: "test-refresh" } + ]) { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const codexHome = await createTempDirectory("spawnfile-daimon-codex-"); + process.env.CODEX_HOME = codexHome; + await writeFile(path.join(codexHome, "auth.json"), JSON.stringify(nativeCredential), { mode: 0o600 }); + await chmod(path.join(codexHome, "auth.json"), 0o600); + const configPath = await writeConfig( + outputDirectory, + "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex" + ); + await expect(prepare(outputDirectory, tempRoot, configPath)).resolves.toMatchObject({ + mountArgs: ["-v", expect.stringContaining("/.daimon-inbound/codex-auth:ro")] + }); + } + }); + it("rejects an insecure caller-provided credential source", async () => { const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); @@ -108,7 +131,7 @@ describe("prepareDaimonRuntimeAuth", () => { const credentials = await Promise.all(["codex", "grok"].map(async (engine) => { const home = await createTempDirectory(`spawnfile-daimon-${engine}-`); const file = "auth.json"; - await writeFile(path.join(home, file), `${engine}-token\n`); + await writeFile(path.join(home, file), engine === "grok" ? grokCredential() : codexCredential()); await chmod(path.join(home, file), 0o600); return { engine, file, home }; })); @@ -131,13 +154,20 @@ describe("prepareDaimonRuntimeAuth", () => { agents: [...credentials.map((credential) => ({ engine: { kind: credential.engine }, id: `agent:${credential.engine}`, - runtimeHomePath: `/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/${credential.engine}` + runtimeHomePath: `/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/${credential.engine}`, + schedule: { kind: "every", interval_ms: 60_000, prompt: "scheduled work" } })), { + engine: { kind: "grok" }, + id: "agent:grok-two", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok-two", + schedule: { kind: "disabled" } + }, { engine: { kind: "agy" }, id: "agent:agy", - runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy", + schedule: { kind: "disabled" } }], - host: {}, version: "noopolis.daimon.organization-runtime.v1" + host: {}, version: "noopolis.daimon.organization-runtime.v2" })); const prepared = await prepareDaimonRuntimeAuth({ @@ -146,13 +176,15 @@ describe("prepareDaimonRuntimeAuth", () => { outputDirectory, tempRoot }); - expect(prepared.mountArgs).toEqual([...credentials].sort((left, right) => - left.engine.localeCompare(right.engine) - ).flatMap((credential) => [ + expect(prepared.mountArgs).toEqual([ "-v", - `${path.join(credential.home, credential.file)}:/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/${credential.engine}/.daimon-inbound/${credential.engine}-auth:ro` - ]).concat(["-v", `${unlock}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro`])); - expect(prepared.launchIdentity).toEqual({ kind: "daimon", uid: process.getuid?.() }); + `${path.join(credentials[0]!.home, credentials[0]!.file)}:/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex/.daimon-inbound/codex-auth:ro`, + "-v", + `${path.join(credentials[1]!.home, credentials[1]!.file)}:/var/lib/spawnfile/daimon/grok-bootstrap-auth:ro`, + "-v", + `${unlock}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro` + ]); + expect(prepared.launchIdentity).toBeUndefined(); expect(prepared.mountArgs.join("\n")).not.toContain("antigravity-oauth-token"); }); @@ -209,7 +241,7 @@ describe("prepareDaimonRuntimeAuth", () => { const grokHome = await createTempDirectory("spawnfile-daimon-grok-home-"); process.env.GROK_HOME = grokHome; - await writeFile(path.join(grokHome, "auth.json"), "grok-auth\n"); + await writeFile(path.join(grokHome, "auth.json"), grokCredential()); await chmod(path.join(grokHome, "auth.json"), 0o600); const grokConfig = await writeConfigSource(outputDirectory, JSON.stringify({ agents: [{ @@ -219,11 +251,61 @@ describe("prepareDaimonRuntimeAuth", () => { host: {}, version: "noopolis.daimon.organization-runtime.v1" })); await expect(prepare(outputDirectory, tempRoot, grokConfig)).resolves.toMatchObject({ - launchIdentity: { kind: "daimon", uid: process.getuid?.() }, - mountArgs: ["-v", expect.stringContaining(".daimon-inbound/grok-auth:ro")] + mountArgs: ["-v", expect.stringContaining(":/var/lib/spawnfile/daimon/grok-bootstrap-auth:ro")] }); }); + it("rejects an empty Grok placeholder before Docker launch without reflecting credential bytes", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const grokHome = await createTempDirectory("spawnfile-daimon-grok-home-"); + process.env.GROK_HOME = grokHome; + await writeFile(path.join(grokHome, "auth.json"), "{}", { mode: 0o600 }); + await chmod(path.join(grokHome, "auth.json"), 0o600); + const configPath = await writeConfigSource(outputDirectory, JSON.stringify({ + agents: [{ engine: { kind: "grok" }, id: "agent:grok", runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok" }], + host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + await expect(prepare(outputDirectory, tempRoot, configPath)).rejects.toThrow(/refreshable subscription credential/u); + }); + + it("matches the broker credential token-length boundary", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const grokHome = await createTempDirectory("spawnfile-daimon-grok-home-"); + process.env.GROK_HOME = grokHome; + const configPath = await writeConfigSource(outputDirectory, JSON.stringify({ + agents: [{ engine: { kind: "grok" }, id: "agent:grok", runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok" }], + host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + for (const [access, refresh, accepted] of [[31, 16, false], [32, 15, false], [32, 16, true]] as const) { + await writeFile(path.join(grokHome, "auth.json"), grokCredential(access, refresh), { mode: 0o600 }); + const result = prepare(outputDirectory, tempRoot, configPath); + if (accepted) await expect(result).resolves.toBeDefined(); + else await expect(result).rejects.toThrow(/refreshable subscription credential/u); + } + }); + + it("redacts rejected Grok credential contents", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); + const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); + const grokHome = await createTempDirectory("spawnfile-daimon-grok-home-"); + process.env.GROK_HOME = grokHome; + await writeFile(path.join(grokHome, "auth.json"), "secret-grok-canary", { mode: 0o600 }); + await chmod(path.join(grokHome, "auth.json"), 0o600); + const configPath = await writeConfigSource(outputDirectory, JSON.stringify({ + agents: [{ engine: { kind: "grok" }, id: "agent:grok", runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok" }], + host: {}, version: "noopolis.daimon.organization-runtime.v1" + })); + try { + await prepare(outputDirectory, tempRoot, configPath); + expect.fail("expected invalid credential rejection"); + } catch (error) { + expect((error as Error).message).toMatch(/refreshable subscription credential/u); + expect((error as Error).message).not.toContain("secret-grok-canary"); + } + }); + it("rejects undeclared source slots and unsafe generated config shapes before mounting", async () => { const outputDirectory = await createTempDirectory("spawnfile-daimon-output-"); const tempRoot = await createTempDirectory("spawnfile-daimon-auth-"); @@ -243,7 +325,7 @@ describe("prepareDaimonRuntimeAuth", () => { const invalidSources = [ ["not-json", /not JSON/u], ["null", /invalid shape/u], - [JSON.stringify({ agents: {}, version: "noopolis.daimon.organization-runtime.v1" }), /not v1/u], + [JSON.stringify({ agents: {}, version: "noopolis.daimon.organization-runtime.v1" }), /supported v1\/v2/u], [JSON.stringify({ agents: [null], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent$/u], [JSON.stringify({ agents: [{ engine: null, id: "agent", runtimeHomePath: home }], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent credential target/u], [JSON.stringify({ agents: [{ engine: { kind: "codex" }, id: "", runtimeHomePath: home }], version: "noopolis.daimon.organization-runtime.v1" }), /invalid agent credential target/u], diff --git a/src/runtime/daimon/runAuth.ts b/src/runtime/daimon/runAuth.ts index c2a269ea..f6a1ce31 100644 --- a/src/runtime/daimon/runAuth.ts +++ b/src/runtime/daimon/runAuth.ts @@ -1,6 +1,7 @@ import os from "node:os"; import path from "node:path"; -import { chmod, lstat, mkdir, readFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, open, readFile } from "node:fs/promises"; import { SpawnfileError } from "../../shared/index.js"; import type { RuntimeAuthPreparationInput, RuntimeAuthPreparationResult } from "../types.js"; @@ -10,10 +11,14 @@ import { assertDaimonRuntimeHome, DAIMON_ENGINE_CREDENTIALS, DAIMON_ENGINE_KINDS, + DAIMON_GROK_SUBSCRIPTION_REALM, + DAIMON_ORGANIZATION_RUNTIME_CONFIG_VERSIONS, type DaimonEngine } from "./contractManifest.js"; const MAX_OPAQUE_CREDENTIAL_BYTES = 64 * 1024; +export const DAIMON_GROK_ACCESS_TOKEN_MIN_BYTES = 32; +export const DAIMON_GROK_REFRESH_TOKEN_MIN_BYTES = 16; export const DAIMON_AGY_UNLOCK_SOURCE_ENV = "SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET"; interface DaimonConfigAgent { @@ -22,18 +27,21 @@ interface DaimonConfigAgent { runtimeHomePath: string; } -const DAIMON_CONFIG_VERSION = "noopolis.daimon.organization-runtime.v1"; - const fail = (message: string): never => { throw new SpawnfileError("validation_error", `Daimon runtime auth ${message}`); }; -const sourceEnvironmentName = (slot: string): string => +export const daimonSourceEnvironmentName = (slot: string): string => `SPAWNFILE_DAIMON_SOURCE_${slot.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`; -const sourcePathForEngine = (engine: Exclude): string => { - const declaredSource = process.env[ - sourceEnvironmentName(DAIMON_ENGINE_CREDENTIALS[engine].sourceSlot) +export const daimonSourcePathForEngine = ( + engine: "codex" | "grok", + environment: Record = process.env +): string => { + const declaredSource = environment[ + daimonSourceEnvironmentName(engine === "grok" + ? DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapSourceSlot + : DAIMON_ENGINE_CREDENTIALS.codex.sourceSlot) ]?.trim(); if (declaredSource) return declaredSource; const home = os.homedir(); @@ -45,10 +53,14 @@ const sourcePathForEngine = (engine: Exclude): string => { } }; -const assertSafeSourceFile = async ( +const sourceFileIdentity = (entry: Awaited>): string => + [entry.dev, entry.ino, entry.size, entry.mtimeMs, entry.uid, entry.mode, entry.nlink].join(":"); + +export const assertSafeDaimonSourceFile = async ( sourcePath: string, label: string, - maxBytes = MAX_OPAQUE_CREDENTIAL_BYTES + maxBytes = MAX_OPAQUE_CREDENTIAL_BYTES, + portableKind?: "codex" | "grok" ): Promise => { let entry: Awaited>; try { @@ -70,9 +82,51 @@ const assertSafeSourceFile = async ( ) { return fail(`selected ${label} artifact must be one bounded caller-owned 0600 regular file`); } + if (portableKind !== undefined) { + let handle: Awaited> | undefined; + let bytes: Buffer | undefined; + try { + handle = await open(sourcePath, constants.O_RDONLY | ((constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0)); + const opened = await handle.stat(); + if (sourceFileIdentity(opened) !== sourceFileIdentity(entry)) { + return fail(`selected ${label} artifact changed during validation`); + } + bytes = await handle.readFile(); + const after = await handle.stat(); + if (sourceFileIdentity(after) !== sourceFileIdentity(opened)) { + return fail(`selected ${label} artifact changed during validation`); + } + if (!hasRefreshableCredential(portableKind, bytes)) { + return fail(`selected ${label} artifact is not a refreshable subscription credential`); + } + } catch (error) { + if (error instanceof SpawnfileError) throw error; + return fail(`selected ${label} artifact could not be validated`); + } finally { + bytes?.fill(0); + await handle?.close().catch(() => undefined); + } + } return callerUid; }; +const hasRefreshableCredential = (kind: "codex" | "grok", bytes: Buffer): boolean => { + let parsed: unknown; + try { parsed = JSON.parse(bytes.toString("utf8")); } catch { return false; } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const root = parsed as Record; + const source = kind === "codex" + ? ((root.tokens && typeof root.tokens === "object" && !Array.isArray(root.tokens)) ? root.tokens as Record : root) + : Object.entries(root).find(([key, value]) => /^https:\/\/auth\.x\.ai::/u.test(key) && value && typeof value === "object" && !Array.isArray(value))?.[1] as Record | undefined; + if (!source) return false; + const access = kind === "grok" ? source.key : source.access_token ?? source.accessToken ?? source.token; + const refresh = kind === "grok" ? source.refresh_token : source.refresh_token ?? source.refreshToken; + if (typeof access !== "string" || !access.trim() || typeof refresh !== "string" || !refresh.trim()) return false; + if (kind === "grok" && (access.length < DAIMON_GROK_ACCESS_TOKEN_MIN_BYTES + || refresh.length < DAIMON_GROK_REFRESH_TOKEN_MIN_BYTES)) return false; + return kind !== "grok" || (typeof source.expires_at === "string" && Number.isFinite(Date.parse(source.expires_at))); +}; + const assertContainedPath = (root: string, candidate: string): string => { const normalizedRoot = path.resolve(root); const normalizedCandidate = path.resolve(candidate); @@ -100,7 +154,7 @@ const parseConfigAgents = (source: string): DaimonConfigAgent[] => { } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) fail("generated organization config has an invalid shape"); const root = parsed as Record; - if (root.version !== DAIMON_CONFIG_VERSION || !Array.isArray(root.agents)) fail("generated organization config is not v1"); + if (!(DAIMON_ORGANIZATION_RUNTIME_CONFIG_VERSIONS as readonly unknown[]).includes(root.version) || !Array.isArray(root.agents)) fail("generated organization config is not a supported v1/v2 contract"); const rawAgents = root.agents as unknown[]; const seenHomes = new Set(); const agents: DaimonConfigAgent[] = []; @@ -130,14 +184,14 @@ const parseConfigAgents = (source: string): DaimonConfigAgent[] => { }; const resolveCredentialSource = async ( - agent: DaimonConfigAgent & { engine: { kind: Exclude } } + agent: DaimonConfigAgent & { engine: { kind: "codex" } } ): Promise => { - return sourcePathForEngine(agent.engine.kind); + return daimonSourcePathForEngine(agent.engine.kind); }; const prepareNeutralIngress = async ( outputDirectory: string, - agent: DaimonConfigAgent & { engine: { kind: Exclude } } + agent: DaimonConfigAgent & { engine: { kind: "codex" } } ): Promise => { const rootfs = path.join(outputDirectory, "container", "rootfs"); const runtimeHome = assertContainedPath(rootfs, path.join(rootfs, `.${agent.runtimeHomePath}`)); @@ -153,17 +207,18 @@ const prepareNeutralIngress = async ( }; /** - * Binds one declared credential leaf per Daimon agent without copying or - * reading its contents. The read-only mount is a generic private ingress; - * Daimon is solely responsible for consuming it into its runtime-owned home. + * Binds Codex leaves per agent and one Grok bootstrap leaf per organization. + * Every leaf is validated from a stable descriptor and mounted read-only; + * Daimon alone owns writable credential state and refresh reconciliation. */ export const prepareDaimonRuntimeAuth = async ( input: RuntimeAuthPreparationInput ): Promise => { const allowedSourceEnvironments = new Set([ ...Object.values(DAIMON_ENGINE_CREDENTIALS).map((credential) => - sourceEnvironmentName(credential.sourceSlot) + daimonSourceEnvironmentName(credential.sourceSlot) ), + daimonSourceEnvironmentName(DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapSourceSlot), DAIMON_AGY_UNLOCK_SOURCE_ENV ]); for (const name of Object.keys(process.env)) { @@ -180,40 +235,35 @@ export const prepareDaimonRuntimeAuth = async ( } const agents = parseConfigAgents(configSource); const mountArgs: string[] = []; - let authorizedUid: number | undefined; for (const agent of agents) { - if (agent.engine.kind === "agy") continue; + if (agent.engine.kind !== "codex") continue; const portableAgent = agent as DaimonConfigAgent & { - engine: { kind: Exclude }; + engine: { kind: "codex" }; }; const sourcePath = await resolveCredentialSource(portableAgent); const ingressPath = await prepareNeutralIngress(input.outputDirectory, portableAgent); - const sourceUid = await assertSafeSourceFile(sourcePath, agent.engine.kind); - if (authorizedUid !== undefined && sourceUid !== authorizedUid) { - return fail("selected credential artifacts must share one authorized UID"); - } - authorizedUid = sourceUid; + await assertSafeDaimonSourceFile(sourcePath, agent.engine.kind, MAX_OPAQUE_CREDENTIAL_BYTES, "codex"); mountArgs.push("-v", `${sourcePath}:${ingressPath}:ro`); } + if (agents.some((agent) => agent.engine.kind === "grok")) { + const sourcePath = daimonSourcePathForEngine("grok"); + await assertSafeDaimonSourceFile( + sourcePath, "grok", DAIMON_GROK_SUBSCRIPTION_REALM.maxCredentialBytes, "grok" + ); + mountArgs.push("-v", `${sourcePath}:${DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapMountPath}:ro`); + } if (agents.some((agent) => agent.engine.kind === "agy")) { const source = process.env[DAIMON_AGY_UNLOCK_SOURCE_ENV]?.trim(); if (!source) return fail("is missing the operator-authorized AGY realm unlock artifact"); - const sourceUid = await assertSafeSourceFile( + await assertSafeDaimonSourceFile( source, "AGY realm unlock", DAIMON_AGY_SUBSCRIPTION_REALM.maxUnlockBytes ); - if (authorizedUid !== undefined && sourceUid !== authorizedUid) { - return fail("selected credential artifacts must share one authorized UID"); - } - authorizedUid = sourceUid; mountArgs.push("-v", `${source}:${DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath}:ro`); } return { coveredModelSecrets: [], - ...(authorizedUid === undefined ? {} : { - launchIdentity: { kind: "daimon" as const, uid: authorizedUid } - }), mountArgs }; }; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index bd5de516..947467cc 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -2,6 +2,7 @@ export * from "./common.js"; export * from "./container.js"; export * from "./containerPackageOverrides.js"; export * from "./install.js"; +export * from "./localDaimonAuthority.js"; export * from "./registry.js"; export * from "./statusProbes.js"; export * from "./types.js"; diff --git a/src/runtime/install.test.ts b/src/runtime/install.test.ts index 264b5e85..391529ec 100644 --- a/src/runtime/install.test.ts +++ b/src/runtime/install.test.ts @@ -20,6 +20,7 @@ describe("runtime install selection", () => { it("resolves Daimon install selection from the pinned runtime image", async () => { await expect(resolveRuntimeInstallSelection("daimon")).resolves.toEqual({ capabilityReceipt: "sha256:1a207c0cc5f081b2a8f941d59b74e37f905a1dc7b37a08c7984c6e39123fb4e7", + contractManifestSha256: "sha256:95ef6c04f1a757b8cd33498207239aa242d0dd6783308530eadb660285b5f83b", digest: "sha256:19b671e589ad8c9e8f1b55610ccbf86ee72f16b4cb2f707ec419f5ef0d6942aa", ecosystem: "node", image: "noopolis/spawnfile-runtime-daimon", diff --git a/src/runtime/install.ts b/src/runtime/install.ts index a3e0dfc8..536985b4 100644 --- a/src/runtime/install.ts +++ b/src/runtime/install.ts @@ -9,6 +9,7 @@ export type RuntimeInstallSelection = | { ecosystem: "go" | "node"; capabilityReceipt?: string; + contractManifestSha256?: string; digest?: string; image: string; installHint: string; @@ -176,6 +177,7 @@ export const resolveRuntimeInstallSelection = async ( return { ecosystem: installProfile.container_image.ecosystem, capabilityReceipt: runtime.install.capabilityReceipt, + contractManifestSha256: runtime.install.contractManifestSha256, digest: runtime.install.digest, image: runtime.install.image, installHint: installProfile.container_image.installHint, diff --git a/src/runtime/localDaimonAuthority.test.ts b/src/runtime/localDaimonAuthority.test.ts new file mode 100644 index 00000000..47955d04 --- /dev/null +++ b/src/runtime/localDaimonAuthority.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + loadLocalDaimonRuntimeIdentity +} from "./localDaimonAuthority.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; + +const digest = (character: string): string => `sha256:${character.repeat(64)}`; +const tempDirectories: string[] = []; +const repository = "127.0.0.1:54321/noopolis/spawnfile-runtime-daimon"; + +const createIdentity = (): Record => ({ + capability_receipt_sha256: digest("a"), + development: { + mode: "local-development", + non_production: true, + unpublished: true, + unsigned: true + }, + image_architecture: "amd64", + image_config_digest: digest("b"), + image_manifest_digest: digest("c"), + image_reference: `${repository}@${digest("c")}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" +}); + +const writeIdentity = async (identity: Record): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-identity-")); + tempDirectories.push(directory); + const identityPath = path.join(directory, "identity.json"); + await writeFile(identityPath, `${JSON.stringify(identity)}\n`, { mode: 0o600 }); + return identityPath; +}; + +afterEach(async () => { + await Promise.all(tempDirectories.splice(0).map((directory) => + rm(directory, { force: true, recursive: true }) + )); +}); + +describe("local Daimon runtime authority", () => { + it("loads an exact non-production identity bound to a registry manifest and receipt digest", async () => { + const identityPath = await writeIdentity(createIdentity()); + + await expect(loadLocalDaimonRuntimeIdentity(identityPath)).resolves.toEqual({ + capabilityReceipt: digest("a"), + imageArchitecture: "amd64", + imageConfigDigest: digest("b"), + imageManifestDigest: digest("c"), + imageReference: `${repository}@${digest("c")}`, + manifestSha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registryAuthority: "127.0.0.1:54321" + }); + }); + + it("rejects a tag-only image", async () => { + const identity = createIdentity(); + identity.image_reference = `${repository}:0.2.0-local`; + + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(identity))).rejects.toThrow( + /invalid or incomplete/u + ); + }); + + it("rejects an arbitrary registry image even when digest-bound", async () => { + const identity = createIdentity(); + identity.image_reference = `registry.invalid/daimon@${digest("c")}`; + + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(identity))).rejects.toThrow( + /invalid or incomplete/u + ); + }); + + it("rejects non-loopback, missing, or mismatched registry authority", async () => { + const remote = createIdentity(); remote.registry_authority = "registry.invalid:54321"; + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(remote))).rejects.toThrow(/invalid or incomplete/u); + const missing = createIdentity(); delete missing.registry_authority; + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(missing))).rejects.toThrow(/invalid or incomplete/u); + const mismatched = createIdentity(); mismatched.registry_authority = "127.0.0.1:54322"; + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(mismatched))).rejects.toThrow(/invalid or incomplete/u); + }); + + it("rejects a missing capability receipt digest", async () => { + const identity = createIdentity(); + delete identity.capability_receipt_sha256; + + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(identity))).rejects.toThrow( + /invalid or incomplete/u + ); + }); + + it("rejects a local image built for any other Daimon contract manifest", async () => { + const identity = createIdentity(); + identity.manifest_sha256 = digest("d"); + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(identity))).rejects.toThrow(/invalid or incomplete/u); + }); + + it("rejects a reference whose manifest digest disagrees with its identity field", async () => { + const identity = createIdentity(); + identity.image_reference = `${repository}@${digest("e")}`; + + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(identity))).rejects.toThrow( + /invalid or incomplete/u + ); + }); + + it("rejects a production-looking or extensible authority document", async () => { + const production = createIdentity(); + production.development = { + mode: "production", + non_production: false, + unpublished: false, + unsigned: false + }; + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(production))).rejects.toThrow( + /invalid or incomplete/u + ); + + const extended = createIdentity(); + extended.image_override = "anything"; + await expect(loadLocalDaimonRuntimeIdentity(await writeIdentity(extended))).rejects.toThrow( + /invalid or incomplete/u + ); + }); + + it("rejects relative authority paths", async () => { + await expect(loadLocalDaimonRuntimeIdentity("identity.json")).rejects.toThrow(/absolute/u); + }); +}); diff --git a/src/runtime/localDaimonAuthority.ts b/src/runtime/localDaimonAuthority.ts new file mode 100644 index 00000000..90f4c831 --- /dev/null +++ b/src/runtime/localDaimonAuthority.ts @@ -0,0 +1,99 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { SpawnfileError } from "../shared/index.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; + +export const DAIMON_LOCAL_RUNTIME_IDENTITY_ENV = + "SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY"; +const LOCAL_DAIMON_REPOSITORY_PATH = "noopolis/spawnfile-runtime-daimon"; + +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const MAX_IDENTITY_BYTES = 16 * 1024; + +const localDaimonRuntimeIdentitySchema = z + .object({ + capability_receipt_sha256: z.string().regex(DIGEST), + development: z + .object({ + mode: z.literal("local-development"), + non_production: z.literal(true), + unpublished: z.literal(true), + unsigned: z.literal(true) + }) + .strict(), + image_architecture: z.literal("amd64"), + image_config_digest: z.string().regex(DIGEST), + image_manifest_digest: z.string().regex(DIGEST), + image_reference: z.string(), + manifest_sha256: z.string().regex(DIGEST), + registry_authority: z.string().regex(/^127\.0\.0\.1:(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/u), + version: z.literal("spawnfile.local-daimon-runtime-identity.v3") + }) + .strict() + .superRefine((identity, context) => { + const expected = `${identity.registry_authority}/${LOCAL_DAIMON_REPOSITORY_PATH}@${identity.image_manifest_digest}`; + if (identity.image_reference !== expected) { + context.addIssue({ + code: "custom", + message: "image_reference must bind the approved local registry repository and manifest digest", + path: ["image_reference"] + }); + } + }); + +export interface LocalDaimonRuntimeIdentity { + capabilityReceipt: string; + imageArchitecture: "amd64"; + imageConfigDigest: string; + imageManifestDigest: string; + imageReference: string; + manifestSha256: string; + registryAuthority: string; +} + +const fail = (message: string): never => { + throw new SpawnfileError("runtime_error", message); +}; + +export const loadLocalDaimonRuntimeIdentity = async ( + identityPath: string +): Promise => { + if (!path.isAbsolute(identityPath)) { + return fail("Local Daimon runtime identity path must be absolute"); + } + + let entry; + try { + entry = await lstat(identityPath); + } catch { + return fail("Local Daimon runtime identity is missing or unreadable"); + } + if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0 || entry.size > MAX_IDENTITY_BYTES) { + return fail("Local Daimon runtime identity must be a bounded nonempty regular file"); + } + + let value: unknown; + try { + value = JSON.parse(await readFile(identityPath, "utf8")); + } catch { + return fail("Local Daimon runtime identity must contain valid JSON"); + } + + const parsed = localDaimonRuntimeIdentitySchema.safeParse(value); + if (!parsed.success || parsed.data.manifest_sha256 !== DAIMON_CONTRACT_MANIFEST_SHA256) { + return fail("Local Daimon runtime identity is invalid or incomplete"); + } + + return Object.freeze({ + capabilityReceipt: parsed.data.capability_receipt_sha256, + imageArchitecture: parsed.data.image_architecture, + imageConfigDigest: parsed.data.image_config_digest, + imageManifestDigest: parsed.data.image_manifest_digest, + imageReference: parsed.data.image_reference, + manifestSha256: parsed.data.manifest_sha256, + registryAuthority: parsed.data.registry_authority + }); +}; diff --git a/src/runtime/registry.ts b/src/runtime/registry.ts index 712f1675..c87a792a 100644 --- a/src/runtime/registry.ts +++ b/src/runtime/registry.ts @@ -24,6 +24,7 @@ const runtimeInstallSchema = z.discriminatedUnion("kind", [ z .object({ capability_receipt: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), + contract_manifest_sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), digest: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(), image: z.string().min(1), kind: z.literal("container_image"), @@ -77,6 +78,7 @@ let runtimeRegistryPromise: Promise | undefined; export type RuntimeRegistryInstall = | { capabilityReceipt?: string; + contractManifestSha256?: string; digest?: string; image: string; kind: "container_image"; @@ -119,6 +121,9 @@ export const parseRuntimeRegistry = (source: string): RuntimeRegistryEntry[] => ...(entry.install.capability_receipt ? { capabilityReceipt: entry.install.capability_receipt } : {}), + ...(entry.install.contract_manifest_sha256 + ? { contractManifestSha256: entry.install.contract_manifest_sha256 } + : {}), ...(entry.install.digest ? { digest: entry.install.digest } : {}), image: entry.install.image, kind: entry.install.kind, diff --git a/src/runtime/types.ts b/src/runtime/types.ts index e0ed7d0f..f663b9c2 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -39,6 +39,7 @@ export interface ContainerTargetEnvFile { export interface ContainerTargetPersistentMount { id: string; + lifecycle?: "exclusive-reattach"; mountPath: string; reason: string; } From e2d85585c0d15b2519652c1f000e6ea4c3571afa Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:54 +0200 Subject: [PATCH 06/34] feat(build): package provenance-bound runtimes --- runtime-images/AGENTS.md | 15 +- runtime-images/daimon/Dockerfile | 85 ++++-- runtime-images/daimon/SourceBundle.Dockerfile | 46 ++++ .../moltnet/SourceBundle.Dockerfile | 18 ++ scripts/build-local-daimon-runtime.mjs | 241 ++++++++++++++--- scripts/build-local-daimon-runtime.test.mjs | 255 ++++++++++++++++-- scripts/build-local-moltnet.mjs | 62 ++++- scripts/build-local-moltnet.test.mjs | 14 +- .../create-linux-amd64-dependency-closure.mjs | 83 ++++++ scripts/create-linux-amd64-go-closure.mjs | 32 +++ scripts/create-source-provenance-bundle.mjs | 13 + ...net-source-provenance.integration.test.mjs | 23 ++ scripts/native-helper-artifacts.mjs | 16 ++ scripts/native-helper-artifacts.test.mjs | 16 ++ scripts/native-helper-integration.test.mjs | 28 ++ scripts/native-helper-workflows.test.mjs | 24 ++ ...rce-provenance-bundle.integration.test.mjs | 123 +++++++++ scripts/source-provenance-bundle.mjs | 214 +++++++++++++++ scripts/verify-package-closure.mjs | 12 +- 19 files changed, 1223 insertions(+), 97 deletions(-) create mode 100644 runtime-images/daimon/SourceBundle.Dockerfile create mode 100644 runtime-images/moltnet/SourceBundle.Dockerfile create mode 100644 scripts/create-linux-amd64-dependency-closure.mjs create mode 100644 scripts/create-linux-amd64-go-closure.mjs create mode 100644 scripts/create-source-provenance-bundle.mjs create mode 100644 scripts/moltnet-source-provenance.integration.test.mjs create mode 100644 scripts/native-helper-artifacts.mjs create mode 100644 scripts/native-helper-artifacts.test.mjs create mode 100644 scripts/native-helper-integration.test.mjs create mode 100644 scripts/native-helper-workflows.test.mjs create mode 100644 scripts/source-provenance-bundle.integration.test.mjs create mode 100644 scripts/source-provenance-bundle.mjs diff --git a/runtime-images/AGENTS.md b/runtime-images/AGENTS.md index f1cb8c82..a06b6ca2 100644 --- a/runtime-images/AGENTS.md +++ b/runtime-images/AGENTS.md @@ -9,16 +9,21 @@ images into generated organization Dockerfiles. The `daimon/` image is a separately versioned generic engine runtime. It contains published Daimon plus exact Codex, Grok, and AGY CLI installations, and carries a capability receipt recording those executable identities. It +also packages Daimon's architecture-specific native engine broker at its exact +source and executable digests; generated images copy it to the fixed root +launcher path before creating any organization registrations. contains no organization config, workspace, credentials, Moltnet state, or browser. Spawnfile selects the immutable image digest and receipt from `runtimes.yaml`; it never constructs engine argv, installs CLIs, or stages engine auth. -The image pipeline supplies pinned Grok/AGY release URLs plus SHA-256 values -and a canonical capability-receipt document. The resulting image is accepted -by Spawnfile only when its immutable image digest and the embedded receipt's -SHA-256 match `runtimes.yaml`; readiness/version probing happens inside -Daimon, never in Spawnfile. +The image pipeline supplies pinned Grok version/URL/executable SHA-256 and AGY +version/URL/archive SHA-512/extracted-executable SHA-256 values plus a canonical +capability-receipt document. AGY's official tar.gz is verified before extraction +and only its `antigravity` executable is installed as `agy`. The resulting image +is accepted only when its immutable image manifest digest and embedded receipt +SHA-256 match either `runtimes.yaml` or the explicit local-development identity. +Readiness/version probing happens inside Daimon, never in Spawnfile. Runtime artifact images are copy sources for generated organization Dockerfiles. They must contain pinned runtime dependencies only, under diff --git a/runtime-images/daimon/Dockerfile b/runtime-images/daimon/Dockerfile index b6b00121..d7438d01 100644 --- a/runtime-images/daimon/Dockerfile +++ b/runtime-images/daimon/Dockerfile @@ -1,61 +1,114 @@ # syntax=docker/dockerfile:1 +ARG NODE_BASE_IMAGE=node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df FROM daimon_package AS daimon_package -ARG NODE_VERSION=24 -FROM node:${NODE_VERSION}-bookworm-slim AS build +FROM ${NODE_BASE_IMAGE} AS offline_dependency_probe +ARG DAIMON_DEPENDENCY_ARCHIVE_SHA256 +ARG DAIMON_PACKAGE_SHA256 +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 +RUN test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON_PACKAGE_SHA256}" \ + && test "$(sha256sum /tmp/dependencies.tar | awk '{print "sha256:" $1}')" = "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" \ + && mkdir -p /probe/node_modules/@noopolis/daimon \ + && tar -xf /tmp/dependencies.tar -C /probe/node_modules \ + && tar -xzf /tmp/daimon.tgz -C /probe/node_modules/@noopolis/daimon --strip-components=1 \ + && test -x /probe/node_modules/@openai/codex/bin/codex.js \ + && 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 ARG CODEX_CLI_VERSION=0.142.3 +ARG GROK_CLI_VERSION ARG GROK_CLI_URL ARG GROK_CLI_SHA256 +ARG AGY_CLI_VERSION ARG AGY_CLI_URL +ARG AGY_CLI_SHA512 ARG AGY_CLI_SHA256 ARG DAIMON_CAPABILITY_RECEIPT_BASE64 ARG DAIMON_MANIFEST_SHA256 ARG DAIMON_PACKAGE_SHA256 ARG DAIMON_SOURCE_SHA256 +ARG DAIMON_DEPENDENCY_MODE=registry +ARG DAIMON_DEPENDENCY_ARCHIVE_SHA256=none 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_URL}" \ +RUN test -n "${GROK_CLI_VERSION}" \ + && test -n "${GROK_CLI_URL}" \ && test -n "${GROK_CLI_SHA256}" \ + && test -n "${AGY_CLI_VERSION}" \ && test -n "${AGY_CLI_URL}" \ + && test -n "${AGY_CLI_SHA512}" \ && test -n "${AGY_CLI_SHA256}" \ && test -n "${DAIMON_CAPABILITY_RECEIPT_BASE64}" \ && test -n "${DAIMON_MANIFEST_SHA256}" \ && test -n "${DAIMON_PACKAGE_SHA256}" \ && test -n "${DAIMON_SOURCE_SHA256}" \ + && { 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}" \ - && apt-get update \ - && apt-get install --yes --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* \ + && 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} \ - && npm install --omit=dev --no-fund --no-audit /tmp/daimon.tgz @openai/codex@${CODEX_CLI_VERSION} \ - && curl -fsSL "${GROK_CLI_URL}" -o /tmp/grok \ + && 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 \ - && curl -fsSL "${AGY_CLI_URL}" -o /tmp/agy \ - && echo "${AGY_CLI_SHA256} /tmp/agy" | sha256sum -c - \ - && install -m 0755 /tmp/agy ${RUNTIME_ROOT}/bin/agy \ - && ln -s ../node_modules/.bin/codex ${RUNTIME_ROOT}/bin/codex \ - && ln -s ../node_modules/.bin/daimon-runtime ${RUNTIME_ROOT}/bin/daimon-runtime \ + && 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 \ + amd64) broker_arch=x64; broker_sha=e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd ;; \ + arm64) broker_arch=arm64; broker_sha=ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d ;; \ + *) echo "Unsupported Daimon engine-broker architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && broker_source=${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/native/daimon-engine-broker \ + && test -x "${broker_source}" \ + && test "$(sha256sum "${broker_source}" | awk '{print $1}')" = "${broker_sha}" \ + && install -m 0555 "${broker_source}" ${RUNTIME_ROOT}/bin/daimon-engine-broker \ + && printf '%s\n' '#!/usr/bin/env node' 'import "../node_modules/@openai/codex/bin/codex.js";' > ${RUNTIME_ROOT}/bin/codex \ + && printf '%s\n' '#!/usr/bin/env node' 'import { runOrganizationRuntimeCli } from "../node_modules/@noopolis/daimon/dist/runtime/cli.js";' 'try { await runOrganizationRuntimeCli(process.argv.slice(2)); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : "daimon-runtime failed"}\\n`); process.exitCode = 1; }' > ${RUNTIME_ROOT}/bin/daimon-runtime \ + && chmod 0755 ${RUNTIME_ROOT}/bin/codex ${RUNTIME_ROOT}/bin/daimon-runtime \ && cp ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.json ${RUNTIME_ROOT}/contract-manifest.json \ && cp ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.sha256 ${RUNTIME_ROOT}/contract-manifest.sha256 \ + && cp /tmp/source-inputs.json ${RUNTIME_ROOT}/source-inputs.json \ && printf '%s' "${DAIMON_CAPABILITY_RECEIPT_BASE64}" | base64 -d > ${RUNTIME_ROOT}/capability-receipt.json \ && expected_manifest="$(cat ${RUNTIME_ROOT}/contract-manifest.sha256)" \ && test "${expected_manifest}" = "${DAIMON_MANIFEST_SHA256}" \ && test "$(sha256sum ${RUNTIME_ROOT}/contract-manifest.json | awk '{print "sha256:" $1}')" = "${expected_manifest}" \ - && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),e={agy:["agy-auth",".daimon-inbound/agy-auth",".antigravity-cli/antigravity-oauth-token"],codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"],grok:["grok-auth",".daimon-inbound/grok-auth",".grok/auth.json"]}; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v1"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(Object.keys(e))||!m.engineCredentialMaterial||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;}))process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ - && node -e 'const fs=require("fs"); const r=JSON.parse(fs.readFileSync(process.argv[1])); const x={codex:process.argv[5],grok:process.argv[6],agy:process.argv[7]}; if(r.version!=="spawnfile.daimon-runtime-capability-receipt.v1"||r.architecture!==process.argv[2]||r.manifest_sha256!==process.argv[3]||r.daimon?.package_sha256!==process.argv[4]||r.daimon?.source_sha256!==process.argv[8]||Object.entries(x).some(([k,v])=>r.engines?.[k]?.executable_sha256!==v))process.exit(1)' ${RUNTIME_ROOT}/capability-receipt.json "${TARGETARCH}" "${expected_manifest}" "${DAIMON_PACKAGE_SHA256}" "${CODEX_CLI_SHA256}" "sha256:${GROK_CLI_SHA256#sha256:}" "sha256:${AGY_CLI_SHA256#sha256:}" "${DAIMON_SOURCE_SHA256}" \ - && test "$(sha256sum ${RUNTIME_ROOT}/bin/codex | awk '{print "sha256:" $1}')" = "${CODEX_CLI_SHA256}" \ + && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),s=["agy","codex","grok"],w=["manual","message","schedule","external"],e={codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"]},a=m.agySubscriptionRealm,g=m.grokSubscriptionRealm,d=m.deliverySemantics; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v3"||m.organizationRuntimeConfigV2Schema?.$id!=="noopolis.daimon.organization-runtime.v2"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(s)||JSON.stringify(m.wakeAcceptanceTypes)!==JSON.stringify(w)||d?.terminalReceiptHorizon!==2048||d?.recovery!=="at-least-once-with-stable-wake-id"||d?.externalEffectsExactlyOnce!==false||!m.engineCredentialMaterial||Object.keys(m.engineCredentialMaterial).length!==1||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;})||!a||a.durableMountPath!=="/var/lib/spawnfile/daimon/agy-subscription-realm"||a.unlockMountPath!=="/var/lib/spawnfile/daimon/agy-unlock-secret"||a.unlockSourceSlot!=="agy-unlock-secret"||a.directoryMode!==448||a.fileMode!==384||a.maxUnlockBytes!==4096||!g||g.durableMountPath!=="/var/lib/spawnfile/daimon/grok-subscription-realm"||g.bootstrapMountPath!=="/var/lib/spawnfile/daimon/grok-bootstrap-auth"||g.bootstrapSourceSlot!=="grok-auth"||g.agentCredentialRelativePath!==".grok/auth.json"||g.directoryMode!==448||g.fileMode!==384||g.maxCredentialBytes!==65536)process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ + && node -e 'const fs=require("fs"); const r=JSON.parse(fs.readFileSync(process.argv[1])); const x={codex:process.argv[5],grok:process.argv[6],agy:process.argv[7]},a=r.provenance?.agy?.archive,g=r.provenance?.grok?.executable,z=process.argv[14],i=JSON.parse(fs.readFileSync(process.argv[15],"utf8")); if(r.version!=="spawnfile.daimon-runtime-capability-receipt.v1"||r.architecture!==process.argv[2]||r.manifest_sha256!==process.argv[3]||r.daimon?.package_sha256!==process.argv[4]||r.daimon?.source_sha256!==process.argv[8]||Object.entries(x).some(([k,v])=>r.engines?.[k]?.executable_sha256!==v)||a?.format!=="tar.gz"||a?.version!==process.argv[9]||a?.url!==process.argv[10]||a?.sha512!==process.argv[11]||g?.version!==process.argv[12]||g?.url!==process.argv[13]||g?.sha256!==x.grok||(z!=="none"&&(r.daimon?.source_inputs?.dependencies?.runtime_archive_sha256!==z||JSON.stringify(i)!==JSON.stringify(r.daimon.source_inputs))))process.exit(1)' ${RUNTIME_ROOT}/capability-receipt.json "${TARGETARCH}" "${expected_manifest}" "${DAIMON_PACKAGE_SHA256}" "${CODEX_CLI_SHA256}" "sha256:${GROK_CLI_SHA256#sha256:}" "sha256:${AGY_CLI_SHA256#sha256:}" "${DAIMON_SOURCE_SHA256}" "${AGY_CLI_VERSION}" "${AGY_CLI_URL}" "sha512:${AGY_CLI_SHA512#sha512:}" "${GROK_CLI_VERSION}" "${GROK_CLI_URL}" "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" /tmp/source-inputs.json \ + && test "$(sha256sum ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js | awk '{print "sha256:" $1}')" = "${CODEX_CLI_SHA256}" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/grok | awk '{print "sha256:" $1}')" = "sha256:${GROK_CLI_SHA256#sha256:}" \ + && 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 diff --git a/runtime-images/daimon/SourceBundle.Dockerfile b/runtime-images/daimon/SourceBundle.Dockerfile new file mode 100644 index 00000000..3071b947 --- /dev/null +++ b/runtime-images/daimon/SourceBundle.Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 +ARG NODE_BASE_IMAGE=node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df +FROM ${NODE_BASE_IMAGE} AS bundle_build +ARG TARGETARCH +ARG SOURCE_ARCHIVE_SHA256 +ARG DEPENDENCY_ARCHIVE_SHA256 +ARG SOURCE_MANIFEST_SHA256 +ARG DEPENDENCY_MANIFEST_SHA256 +COPY --from=source_bundle /source.tar /tmp/source.tar +COPY --from=dependency_bundle /dependencies.tar /tmp/dependencies.tar +RUN test "$(sha256sum /tmp/source.tar | awk '{print "sha256:" $1}')" = "${SOURCE_ARCHIVE_SHA256}" \ + && test "$(sha256sum /tmp/dependencies.tar | awk '{print "sha256:" $1}')" = "${DEPENDENCY_ARCHIVE_SHA256}" \ + && test -n "${SOURCE_MANIFEST_SHA256}" \ + && test -n "${DEPENDENCY_MANIFEST_SHA256}" \ + && mkdir -p /src /closure /out \ + && tar -xf /tmp/source.tar -C /src \ + && tar -xf /tmp/dependencies.tar -C /closure \ + && rm /src/.spawnfile-source-manifest.json \ + && rm /closure/.spawnfile-source-manifest.json \ + && cd /closure \ + && npm ci --offline --ignore-scripts --cache /closure/npm-cache \ + && npm ls --all \ + && cp -a /closure/node_modules /src/node_modules \ + && cd /src \ + && rm -rf dist \ + && node node_modules/typescript/bin/tsc --project tsconfig.build.json \ + && node --input-type=module -e 'import fs from "node:fs"; import crypto from "node:crypto"; import { RUNTIME_CONTRACT_MANIFEST as m } from "./dist/contracts/runtimeContractManifest.js"; const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const bytes=Buffer.from(c(m)+"\n"); fs.writeFileSync("dist/runtime/contract-manifest.json",bytes); fs.writeFileSync("dist/runtime/contract-manifest.sha256","sha256:"+crypto.createHash("sha256").update(bytes).digest("hex")+"\n")' \ + && node -e 'const expected={amd64:"x64",arm64:"arm64"}[process.argv[1]]; if (!expected || process.platform!=="linux" || process.arch!==expected) process.exit(1)' "${TARGETARCH}" \ + && DAIMON_REQUIRE_ENGINE_BROKER=1 node src/runtime/native/copyArtifact.mjs \ + && test -x dist/runtime/native/daimon-engine-broker \ + && node src/runtime/native/verifyArtifacts.mjs \ + && npm pack --ignore-scripts --offline --pack-destination /out \ + && package="$(find /out -maxdepth 1 -type f -name '*.tgz' -print)" \ + && test -n "${package}" \ + && test "$(find /out -maxdepth 1 -type f -name '*.tgz' | wc -l)" -eq 1 \ + && mv "${package}" /out/daimon.tgz \ + && cd /closure \ + && npm prune --omit=dev --offline --ignore-scripts --cache /closure/npm-cache \ + && npm ls --omit=dev --all \ + && tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner -cf /out/runtime-dependencies.tar -C /closure/node_modules . \ + && printf '{"dependencies":{"archive_sha256":"%s","manifest_sha256":"%s"},"source":{"archive_sha256":"%s","manifest_sha256":"%s"},"target":"linux/%s","version":"spawnfile.daimon-bundle-build.v1"}\n' "${DEPENDENCY_ARCHIVE_SHA256}" "${DEPENDENCY_MANIFEST_SHA256}" "${SOURCE_ARCHIVE_SHA256}" "${SOURCE_MANIFEST_SHA256}" "${TARGETARCH}" > /out/source-inputs.json + +FROM scratch +COPY --from=bundle_build /out/daimon.tgz /daimon.tgz +COPY --from=bundle_build /out/source-inputs.json /source-inputs.json +COPY --from=bundle_build /out/runtime-dependencies.tar /runtime-dependencies.tar diff --git a/runtime-images/moltnet/SourceBundle.Dockerfile b/runtime-images/moltnet/SourceBundle.Dockerfile new file mode 100644 index 00000000..2d6a2766 --- /dev/null +++ b/runtime-images/moltnet/SourceBundle.Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 +ARG GO_IMAGE=golang:1.24-bookworm@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac +FROM ${GO_IMAGE} AS build +ARG SOURCE_ARCHIVE_SHA256 +ARG DEPENDENCY_ARCHIVE_SHA256 +COPY --from=source_bundle /source.tar /tmp/source.tar +COPY --from=dependency_bundle /dependencies.tar /tmp/dependencies.tar +RUN test "$(sha256sum /tmp/source.tar | awk '{print "sha256:" $1}')" = "${SOURCE_ARCHIVE_SHA256}" \ + && test "$(sha256sum /tmp/dependencies.tar | awk '{print "sha256:" $1}')" = "${DEPENDENCY_ARCHIVE_SHA256}" \ + && mkdir /src /closure /out && tar -xf /tmp/source.tar -C /src && tar -xf /tmp/dependencies.tar -C /closure \ + && rm /src/.spawnfile-source-manifest.json /closure/.spawnfile-source-manifest.json \ + && cd /src && test "$(sha256sum go.mod | awk '{print "sha256:" $1}')" = "$(sha256sum /closure/go.mod | awk '{print "sha256:" $1}')" \ + && test "$(sha256sum go.sum | awk '{print "sha256:" $1}')" = "$(sha256sum /closure/go.sum | awk '{print "sha256:" $1}')" \ + && GOMODCACHE=/closure/gomodcache GOPROXY=off GOSUMDB=off go mod verify \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOMAXPROCS=1 GOMODCACHE=/closure/gomodcache GOPROXY=off GOSUMDB=off go build -p=1 -trimpath -ldflags '-s -w' -o /out/moltnet ./cmd/moltnet \ + && test -x /out/moltnet +FROM scratch +COPY --from=build /out/moltnet /moltnet diff --git a/scripts/build-local-daimon-runtime.mjs b/scripts/build-local-daimon-runtime.mjs index e0343f0c..82108ce5 100644 --- a/scripts/build-local-daimon-runtime.mjs +++ b/scripts/build-local-daimon-runtime.mjs @@ -4,12 +4,13 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { hashTrackedSourceEntries } from "./build-local-moltnet.mjs"; +import { validateSourceBundle } from "./source-provenance-bundle.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const configuredDaimonSource = process.env.SPAWNFILE_DAIMON_SOURCE_DIR?.trim(); @@ -17,20 +18,97 @@ const daimonDir = configuredDaimonSource ? path.resolve(configuredDaimonSource) : path.resolve(repoRoot, "..", "daimon"); const sha256 = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`; -const digest = /^[a-f0-9]{64}$/u; +const LOCAL_REPOSITORY_PATH = "noopolis/spawnfile-runtime-daimon"; +const sha256Digest = /^[a-f0-9]{64}$/u; +const sha512Digest = /^[a-f0-9]{128}$/u; +const version = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$/u; +const LOCAL_DEVELOPMENT_PROVENANCE = Object.freeze({ + mode: "local-development", + non_production: true, + unsigned: true, + unpublished: true +}); -const requiredDigest = (name) => { - const value = process.env[name]?.trim(); - if (!value || !digest.test(value.replace(/^sha256:/u, ""))) throw new Error(`${name} must be a SHA-256 digest`); - return `sha256:${value.replace(/^sha256:/u, "")}`; +const requiredDigest = (env, name, algorithm, pattern) => { + const value = env[name]?.trim(); + const bare = value?.replace(new RegExp(`^${algorithm}:`, "u"), ""); + if (!bare || !pattern.test(bare)) throw new Error(`${name} must be a ${algorithm.toUpperCase()} digest`); + return `${algorithm}:${bare}`; }; -const requiredUrl = (name) => { - const value = process.env[name]?.trim(); - if (!value || !/^https:\/\//u.test(value)) throw new Error(`${name} must be an HTTPS URL`); +const requiredUrl = (env, name) => { + const value = env[name]?.trim(); + let parsed; + try { + parsed = value ? new URL(value) : null; + } catch { + parsed = null; + } + if ( + !parsed || parsed.protocol !== "https:" || parsed.username || parsed.password || + parsed.search || parsed.hash + ) { + throw new Error(`${name} must be a credential-free HTTPS URL without query or fragment`); + } return value; }; +const requiredVersion = (env, name) => { + const value = env[name]?.trim(); + if (!value || !version.test(value)) throw new Error(`${name} must be an explicit artifact version`); + return value; +}; + +export const readDaimonCliArtifactPins = (env = process.env) => ({ + agy: { + archive_sha512: requiredDigest(env, "AGY_CLI_SHA512", "sha512", sha512Digest), + executable_sha256: requiredDigest(env, "AGY_CLI_SHA256", "sha256", sha256Digest), + url: requiredUrl(env, "AGY_CLI_URL"), + version: requiredVersion(env, "AGY_CLI_VERSION") + }, + codex: { + executable_sha256: requiredDigest(env, "CODEX_CLI_SHA256", "sha256", sha256Digest) + }, + grok: { + executable_sha256: requiredDigest(env, "GROK_CLI_SHA256", "sha256", sha256Digest), + url: requiredUrl(env, "GROK_CLI_URL"), + version: requiredVersion(env, "GROK_CLI_VERSION") + } +}); + +export const resolveLocalImageTag = (value) => { + const tag = value?.trim(); + const match = tag?.match(/^127\.0\.0\.1:((?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))\/noopolis\/spawnfile-runtime-daimon:([A-Za-z0-9_][A-Za-z0-9_.-]{0,127})$/u); + const port = Number(match?.[1]); const label = match?.[2] ?? ""; + const repository = match ? `127.0.0.1:${port}/${LOCAL_REPOSITORY_PATH}` : ""; + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG must use an explicit 127.0.0.1 loopback registry port"); + if (!label || label === "latest" || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u.test(label)) { + throw new Error(`SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG must be an explicit non-latest tag under ${repository}`); + } + return tag; +}; + +export const resolveLocalBuildArchitecture = (hostArchitecture) => { + if (hostArchitecture !== "x64" && hostArchitecture !== "arm64") { + throw new Error("Local Daimon builds require an x64 or arm64 Docker host for the linux/amd64 artifact"); + } + return "amd64"; +}; + +export const resolvePushedImageReference = (imageTag, repoDigests) => { + const repository = imageTag.slice(0, imageTag.lastIndexOf(":")); + resolveLocalImageTag(imageTag); + const expectedPrefix = `${repository}@`; + const matches = repoDigests.filter((value) => value.startsWith(expectedPrefix)); + if (matches.length !== 1 || !new RegExp(`^${expectedPrefix.replaceAll(".", "\\.")}sha256:[a-f0-9]{64}$`, "u").test(matches[0])) { + throw new Error("Docker did not return one immutable local Daimon image manifest digest"); + } + if (imageTag.slice(0, imageTag.lastIndexOf(":")) !== repository) { + throw new Error("Local Daimon image tag and pushed repository disagree"); + } + return matches[0]; +}; + const trackedEntries = (root) => execFileSync("git", ["-C", root, "ls-files", "-s", "-z"], { encoding: "utf8" }) .split("\0").filter(Boolean).map((entry) => { const tab = entry.indexOf("\t"); @@ -50,19 +128,95 @@ const stagePackagedDaimon = (directory) => { if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0) throw new Error("SPAWNFILE_DAIMON_PACKAGE_TARBALL must be a nonempty regular file"); const staged = path.join(directory, "daimon.tgz"); copyFileSync(source, staged); + writeFileSync(path.join(directory, "dependencies.tar"), "clean-git-network-mode\n", { mode: 0o600 }); + writeFileSync(path.join(directory, "source-inputs.json"), '{"mode":"clean-git"}\n', { mode: 0o600 }); + writeFileSync(path.join(directory, "agy.tar.gz"), "registry-mode\n", { mode: 0o600 }); writeFileSync(path.join(directory, "grok"), "registry-mode\n", { mode: 0o600 }); return staged; }; -export const createLocalDaimonCapabilityReceipt = ({ architecture, manifestSha256, packageSha256, sourceSha256 }) => ({ +const requiredBundle = (envName, stagedName, directory, expectedProfile) => { + const source = process.env[envName]?.trim(); + if (!source || !path.isAbsolute(source)) throw new Error(`${envName} must be an absolute deterministic source-provenance tar`); + const entry = lstatSync(source); if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0) throw new Error(`${envName} must be a nonempty regular file`); + const bytes = readFileSync(source), provenance = validateSourceBundle(bytes), staged = path.join(directory, stagedName); + if (provenance.manifest.exclude_policy.profile !== expectedProfile) throw new Error(`${envName} has the wrong provenance profile`); + copyFileSync(source, staged); return { ...provenance, path: staged }; +}; + +export const resolveDaimonSourceMode = (env = process.env) => { + const source = env.SPAWNFILE_DAIMON_SOURCE_BUNDLE?.trim(), dependencies = env.SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE?.trim(); + if (!source && !dependencies) return "clean-git"; + if (!source || !dependencies) throw new Error("Archive provenance requires both SPAWNFILE_DAIMON_SOURCE_BUNDLE and SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE"); + return "source-bundle"; +}; + +const stageOfflineCliAssets = (directory, artifacts) => { + for (const [envName, destination, expected, algorithm] of [["SPAWNFILE_AGY_CLI_ARCHIVE", "agy.tar.gz", artifacts.agy.archive_sha512, "sha512"], ["SPAWNFILE_GROK_CLI_FILE", "grok", artifacts.grok.executable_sha256, "sha256"]]) { + const source = process.env[envName]?.trim(); if (!source || !path.isAbsolute(source)) throw new Error(`${envName} must be an absolute pinned offline CLI asset`); + const item = lstatSync(source); if (!item.isFile() || item.isSymbolicLink() || item.size === 0) throw new Error(`${envName} must be a nonempty regular file`); + const actual = `${algorithm}:${createHash(algorithm).update(readFileSync(source)).digest("hex")}`; if (actual !== expected) throw new Error(`${envName} checksum disagrees with its artifact pin`); + copyFileSync(source, path.join(directory, destination)); + } +}; + +const stageBundleBuiltDaimon = (directory, artifacts) => { + 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}`, + "--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}`, + "-f", path.join(repoRoot, "runtime-images", "daimon", "SourceBundle.Dockerfile"), repoRoot], { stdio: "inherit" }); + const builtPackagePath = path.join(output, "daimon.tgz"), packagePath = path.join(directory, "daimon.tgz"); + if (!existsSync(builtPackagePath)) throw new Error("Remote bundle build did not produce daimon.tgz"); + copyFileSync(builtPackagePath, packagePath); + const runtimeDependencies = path.join(output, "runtime-dependencies.tar"); + if (!existsSync(runtimeDependencies)) throw new Error("Remote bundle build did not produce its runtime dependency closure"); + const runtimeArchiveSha256 = sha256(readFileSync(runtimeDependencies)); copyFileSync(runtimeDependencies, path.join(directory, "dependencies.tar")); + const sourceInputs = { dependencies: { archive_sha256: dependencies.archive_sha256, manifest_sha256: dependencies.manifest_sha256, + package_lock_sha256: dependencies.manifest.dependency_lock.package_lock_sha256, runtime_archive_sha256: runtimeArchiveSha256 }, + 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 || + 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"); + } + writeFileSync(path.join(directory, "source-inputs.json"), `${JSON.stringify(sourceInputs)}\n`, { mode: 0o600 }); + stageOfflineCliAssets(directory, artifacts); + return { packagePath, sourceInputs, sourceSha256: sha256(Buffer.from(JSON.stringify(sourceInputs))) }; +}; + +export const createLocalDaimonCapabilityReceipt = ({ architecture, artifacts, manifestSha256, packageSha256, sourceInputs, sourceSha256 }) => ({ architecture, - daimon: { package_sha256: packageSha256, source_sha256: sourceSha256 }, + daimon: { package_sha256: packageSha256, source_sha256: sourceSha256, ...(sourceInputs ? { source_inputs: sourceInputs } : {}) }, engines: { - agy: { executable_sha256: requiredDigest("AGY_CLI_SHA256") }, - codex: { executable_sha256: requiredDigest("CODEX_CLI_SHA256") }, - grok: { executable_sha256: requiredDigest("GROK_CLI_SHA256") } + agy: { executable_sha256: artifacts.agy.executable_sha256 }, + codex: { executable_sha256: artifacts.codex.executable_sha256 }, + grok: { executable_sha256: artifacts.grok.executable_sha256 } }, manifest_sha256: manifestSha256, - provenance: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + provenance: { + agy: { + archive: { + format: "tar.gz", + sha512: artifacts.agy.archive_sha512, + url: artifacts.agy.url, + version: artifacts.agy.version + } + }, + grok: { + executable: { + sha256: artifacts.grok.executable_sha256, + url: artifacts.grok.url, + version: artifacts.grok.version + } + }, + ...LOCAL_DEVELOPMENT_PROVENANCE + }, version: "spawnfile.daimon-runtime-capability-receipt.v1" }); @@ -70,51 +224,64 @@ const main = () => { if (configuredDaimonSource && !path.isAbsolute(configuredDaimonSource)) { throw new Error("SPAWNFILE_DAIMON_SOURCE_DIR must be absolute"); } - if (!existsSync(path.join(daimonDir, ".git"))) throw new Error(`Missing sibling Daimon checkout: ${daimonDir}`); - assertClean(daimonDir); - const manifestPath = path.join(daimonDir, "dist", "runtime", "contract-manifest.json"); - if (!existsSync(manifestPath)) throw new Error("Local Daimon package must contain dist/runtime/contract-manifest.json"); - const imageTag = process.env.SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG?.trim(); - if (!imageTag || imageTag.includes("@") || imageTag.endsWith(":latest")) { - throw new Error("SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG must be an explicit non-latest local tag"); + const sourceMode = resolveDaimonSourceMode(); + if (sourceMode === "clean-git") { + if (!existsSync(path.join(daimonDir, ".git"))) throw new Error(`Missing sibling Daimon checkout: ${daimonDir}`); + assertClean(daimonDir); } - const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null; - if (!architecture) throw new Error(`Unsupported local Daimon architecture: ${process.arch}`); + const imageTag = resolveLocalImageTag(process.env.SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG); + const architecture = resolveLocalBuildArchitecture(process.arch); + const artifacts = readDaimonCliArtifactPins(); const packageDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-daimon-package-")); try { - const packagePath = stagePackagedDaimon(packageDirectory); + const bundled = sourceMode === "source-bundle" ? stageBundleBuiltDaimon(packageDirectory, artifacts) : null; + const packagePath = bundled?.packagePath ?? stagePackagedDaimon(packageDirectory); + const manifestBytes = execFileSync("tar", ["-xOf", packagePath, "package/dist/runtime/contract-manifest.json"]); const receipt = createLocalDaimonCapabilityReceipt({ architecture, - manifestSha256: sha256(readFileSync(manifestPath)), + artifacts, + manifestSha256: sha256(manifestBytes), packageSha256: sha256(readFileSync(packagePath)), - sourceSha256: hashTrackedSourceEntries(daimonDir, trackedEntries(daimonDir)) + sourceInputs: bundled?.sourceInputs, + sourceSha256: bundled?.sourceSha256 ?? hashTrackedSourceEntries(daimonDir, trackedEntries(daimonDir)) }); const receiptBytes = Buffer.from(`${JSON.stringify(receipt)}\n`); - execFileSync("docker", ["build", "--platform", `linux/${architecture}`, "--build-context", `daimon_package=${packageDirectory}`, + execFileSync("docker", ["build", ...(bundled ? ["--network=none"] : []), "--platform", `linux/${architecture}`, "--build-context", `daimon_package=${packageDirectory}`, "-f", path.join(repoRoot, "runtime-images", "daimon", "Dockerfile"), "-t", imageTag, "--build-arg", `DAIMON_CAPABILITY_RECEIPT_BASE64=${receiptBytes.toString("base64")}`, "--build-arg", `DAIMON_MANIFEST_SHA256=${receipt.manifest_sha256}`, "--build-arg", `DAIMON_PACKAGE_SHA256=${receipt.daimon.package_sha256}`, "--build-arg", `DAIMON_SOURCE_SHA256=${receipt.daimon.source_sha256}`, - "--build-arg", `CODEX_CLI_SHA256=${receipt.engines.codex.executable_sha256}`, - "--build-arg", `GROK_CLI_URL=${requiredUrl("GROK_CLI_URL")}`, + "--build-arg", `DAIMON_DEPENDENCY_MODE=${bundled ? "offline-bundle" : "registry"}`, + "--build-arg", `DAIMON_DEPENDENCY_ARCHIVE_SHA256=${bundled?.sourceInputs.dependencies.runtime_archive_sha256 ?? "none"}`, + "--build-arg", `CODEX_CLI_SHA256=${artifacts.codex.executable_sha256}`, + "--build-arg", `GROK_CLI_VERSION=${artifacts.grok.version}`, + "--build-arg", `GROK_CLI_URL=${artifacts.grok.url}`, "--build-arg", `GROK_CLI_SHA256=${receipt.engines.grok.executable_sha256.slice("sha256:".length)}`, - "--build-arg", `AGY_CLI_URL=${requiredUrl("AGY_CLI_URL")}`, + "--build-arg", `AGY_CLI_VERSION=${artifacts.agy.version}`, + "--build-arg", `AGY_CLI_URL=${artifacts.agy.url}`, + "--build-arg", `AGY_CLI_SHA512=${artifacts.agy.archive_sha512.slice("sha512:".length)}`, "--build-arg", `AGY_CLI_SHA256=${receipt.engines.agy.executable_sha256.slice("sha256:".length)}`, repoRoot ], { stdio: "inherit" }); - const [imageConfigDigest, imageArchitecture] = execFileSync( - "docker", ["image", "inspect", "--format", "{{.Id}}\n{{.Architecture}}", imageTag], { encoding: "utf8" } + execFileSync("docker", ["push", imageTag], { stdio: "inherit" }); + const [imageConfigDigest, imageArchitecture, repoDigestsJson] = execFileSync( + "docker", ["image", "inspect", "--format", "{{.Id}}\n{{.Architecture}}\n{{json .RepoDigests}}", imageTag], { encoding: "utf8" } ).trim().split("\n"); if (!/^sha256:[a-f0-9]{64}$/u.test(imageConfigDigest)) throw new Error("Docker did not return an immutable image config digest"); if (imageArchitecture !== architecture) throw new Error("Docker image architecture does not match the selected local Daimon inputs"); + const imageReference = resolvePushedImageReference(imageTag, JSON.parse(repoDigestsJson)); + const imageManifestDigest = imageReference.slice(imageReference.indexOf("@") + 1); + const registryAuthority = imageTag.slice(0, imageTag.indexOf("/")); writeFileSync(path.join(repoRoot, ".local-daimon-runtime-identity.json"), `${JSON.stringify({ - capability_receipt_sha256: sha256(receiptBytes), development: receipt.provenance, + capability_receipt_sha256: sha256(receiptBytes), development: LOCAL_DEVELOPMENT_PROVENANCE, image_architecture: imageArchitecture, image_config_digest: imageConfigDigest, - image_reference: imageTag, manifest_sha256: receipt.manifest_sha256, - version: "spawnfile.local-daimon-runtime-identity.v1" + image_manifest_digest: imageManifestDigest, image_reference: imageReference, + manifest_sha256: receipt.manifest_sha256, + registry_authority: registryAuthority, + version: "spawnfile.local-daimon-runtime-identity.v3" })}\n`); - process.stdout.write(`Built local-development Daimon image ${imageTag} (${imageConfigDigest})\n`); + process.stdout.write(`Built local-development Daimon image ${imageReference} (${imageConfigDigest})\n`); } finally { rmSync(packageDirectory, { force: true, recursive: true }); } diff --git a/scripts/build-local-daimon-runtime.test.mjs b/scripts/build-local-daimon-runtime.test.mjs index d4f58415..ee6f2f35 100644 --- a/scripts/build-local-daimon-runtime.test.mjs +++ b/scripts/build-local-daimon-runtime.test.mjs @@ -1,30 +1,239 @@ import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import { createLocalDaimonCapabilityReceipt } from "./build-local-daimon-runtime.mjs"; +import { + createLocalDaimonCapabilityReceipt, + readDaimonCliArtifactPins, + resolveDaimonSourceMode, + resolveLocalBuildArchitecture, + resolveLocalImageTag, + resolvePushedImageReference +} from "./build-local-daimon-runtime.mjs"; +import { collectSourceManifest, createSourceBundle, validateSourceBundle } from "./source-provenance-bundle.mjs"; + +const digest = (character, length = 64) => character.repeat(length); +const artifactEnvironment = () => ({ + AGY_CLI_SHA256: digest("a"), + AGY_CLI_SHA512: digest("b", 128), + AGY_CLI_URL: "https://example.invalid/agy/antigravity-linux-amd64.tar.gz", + AGY_CLI_VERSION: "1.2.3", + CODEX_CLI_SHA256: digest("c"), + GROK_CLI_SHA256: digest("d"), + GROK_CLI_URL: "https://example.invalid/grok/grok-linux-amd64", + GROK_CLI_VERSION: "0.9.0" +}); + +test("local Daimon receipt binds engine executables and credential-free artifact provenance", () => { + const artifacts = readDaimonCliArtifactPins(artifactEnvironment()); + const receipt = createLocalDaimonCapabilityReceipt({ + architecture: "amd64", + artifacts, + manifestSha256: `sha256:${digest("e")}`, + packageSha256: `sha256:${digest("f")}`, + sourceSha256: `sha256:${digest("0")}` + }); -test("local Daimon receipt binds all three engine identities and manifest digest", () => { - const previous = { AGY_CLI_SHA256: process.env.AGY_CLI_SHA256, CODEX_CLI_SHA256: process.env.CODEX_CLI_SHA256, GROK_CLI_SHA256: process.env.GROK_CLI_SHA256 }; - process.env.AGY_CLI_SHA256 = "a".repeat(64); - process.env.CODEX_CLI_SHA256 = "b".repeat(64); - process.env.GROK_CLI_SHA256 = "c".repeat(64); - const receipt = createLocalDaimonCapabilityReceipt({ architecture: "amd64", manifestSha256: `sha256:${"d".repeat(64)}`, packageSha256: `sha256:${"e".repeat(64)}`, sourceSha256: `sha256:${"f".repeat(64)}` }); assert.deepEqual(Object.keys(receipt.engines).sort(), ["agy", "codex", "grok"]); - assert.equal(receipt.daimon.package_sha256, `sha256:${"e".repeat(64)}`); + assert.equal(receipt.engines.agy.executable_sha256, `sha256:${digest("a")}`); + assert.deepEqual(receipt.provenance.agy.archive, { + format: "tar.gz", + sha512: `sha512:${digest("b", 128)}`, + url: "https://example.invalid/agy/antigravity-linux-amd64.tar.gz", + version: "1.2.3" + }); + assert.deepEqual(receipt.provenance.grok.executable, { + sha256: `sha256:${digest("d")}`, + url: "https://example.invalid/grok/grok-linux-amd64", + version: "0.9.0" + }); assert.equal(receipt.provenance.mode, "local-development"); - for (const [name, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } -}); - -test("local Daimon receipt rejects a missing engine digest", () => { - const previous = process.env.AGY_CLI_SHA256; - delete process.env.AGY_CLI_SHA256; - assert.throws(() => createLocalDaimonCapabilityReceipt({ - architecture: "amd64", manifestSha256: `sha256:${"d".repeat(64)}`, - packageSha256: `sha256:${"e".repeat(64)}`, sourceSha256: `sha256:${"f".repeat(64)}` - }), /AGY_CLI_SHA256/u); - if (previous === undefined) delete process.env.AGY_CLI_SHA256; - else process.env.AGY_CLI_SHA256 = previous; +}); + +test("artifact pins reject missing AGY archive SHA-512", () => { + const env = artifactEnvironment(); + delete env.AGY_CLI_SHA512; + assert.throws(() => readDaimonCliArtifactPins(env), /AGY_CLI_SHA512/u); +}); + +test("artifact pins reject credential-bearing URLs without disclosing credentials", () => { + const env = artifactEnvironment(); + env.AGY_CLI_URL = "https://user:super-secret@example.invalid/agy.tar.gz"; + + let message = ""; + assert.throws(() => readDaimonCliArtifactPins(env), (error) => { + message = error.message; + return /AGY_CLI_URL/u.test(message); + }); + assert.doesNotMatch(message, /super-secret/u); +}); + +test("local image authority accepts an ephemeral loopback registry and immutable pushed digest", () => { + const tag = "127.0.0.1:54321/noopolis/spawnfile-runtime-daimon:0.2.0-local"; + const immutable = `127.0.0.1:54321/noopolis/spawnfile-runtime-daimon@sha256:${digest("1")}`; + + assert.equal(resolveLocalImageTag(tag), tag); + assert.equal(resolvePushedImageReference(tag, [immutable]), immutable); + assert.throws(() => resolveLocalImageTag("127.0.0.1:54321/noopolis/spawnfile-runtime-daimon:latest"), /non-latest/u); + assert.throws(() => resolveLocalImageTag("registry.invalid/daimon:local"), /127\.0\.0\.1/u); + assert.throws(() => resolveLocalImageTag("0.0.0.0:54321/noopolis/spawnfile-runtime-daimon:local"), /127\.0\.0\.1/u); + assert.throws(() => resolvePushedImageReference(tag, []), /manifest digest/u); +}); + +test("local build architecture fails closed outside the official AGY linux_amd64 target", () => { + assert.equal(resolveLocalBuildArchitecture("x64"), "amd64"); + assert.equal(resolveLocalBuildArchitecture("arm64"), "amd64"); + assert.throws(() => resolveLocalBuildArchitecture("riscv64"), /linux\/amd64/u); +}); + +test("archive provenance is explicit and cannot silently fall back to Git", () => { + assert.equal(resolveDaimonSourceMode({}), "clean-git"); + assert.equal(resolveDaimonSourceMode({ SPAWNFILE_DAIMON_SOURCE_BUNDLE: "/source.tar", SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE: "/deps.tar" }), "source-bundle"); + assert.throws(() => resolveDaimonSourceMode({ SPAWNFILE_DAIMON_SOURCE_BUNDLE: "/source.tar" }), /requires both/u); +}); + +test("source bundle includes intended tracked and untracked bytes while excluding VCS, secrets, and generated caches", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-bundle-")); + try { + mkdirSync(path.join(root, ".git")); mkdirSync(path.join(root, "node_modules")); mkdirSync(path.join(root, "src")); + writeFileSync(path.join(root, "src", "tracked.ts"), "tracked\n"); writeFileSync(path.join(root, "untracked.ts"), "untracked\n"); + writeFileSync(path.join(root, ".env.production"), "TOKEN=secret\n"); writeFileSync(path.join(root, "node_modules", "cache"), "generated\n"); + symlinkSync("tracked.ts", path.join(root, "src", "alias.ts")); + const archive = path.join(root, "..", `${path.basename(root)}.tar`), receipt = createSourceBundle(root, archive); + const verified = validateSourceBundle(readFileSync(archive)); + assert.equal(verified.archive_sha256, receipt.archive_sha256); assert.equal(verified.manifest_sha256, receipt.manifest_sha256); + assert.deepEqual(verified.manifest.entries.map((entry) => entry.path), ["src", "src/alias.ts", "src/tracked.ts", "untracked.ts"]); + assert.doesNotMatch(readFileSync(archive).toString("utf8"), /TOKEN=secret|generated\n/u); + } finally { rmSync(root, { force: true, recursive: true }); rmSync(path.join(root, "..", `${path.basename(root)}.tar`), { force: true }); } +}); + +test("source bundle rejects traversal and byte drift", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-hostile-")); + try { + writeFileSync(path.join(root, "input.ts"), "one\n"); + const manifest = collectSourceManifest(root); writeFileSync(path.join(root, "input.ts"), "two\n"); + assert.notDeepEqual(collectSourceManifest(root), manifest); + const archive = path.join(root, "..", `${path.basename(root)}.tar`); createSourceBundle(root, archive); + const bytes = readFileSync(archive); bytes.write("../escape", 0, "utf8"); + assert.throws(() => validateSourceBundle(bytes), /checksum|unsafe/u); + } finally { rmSync(root, { force: true, recursive: true }); rmSync(path.join(root, "..", `${path.basename(root)}.tar`), { force: true }); } +}); + +test("source bundle excludes credential stores and rejects credential-shaped content", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-secret-")); + try { + mkdirSync(path.join(root, ".ssh")); mkdirSync(path.join(root, ".aws")); mkdirSync(path.join(root, ".docker")); + writeFileSync(path.join(root, ".npmrc"), "//registry.invalid/:_authToken=actual-secret-value\n"); + writeFileSync(path.join(root, ".ssh", "id_ed25519"), "private\n"); writeFileSync(path.join(root, ".aws", "credentials"), "private\n"); + writeFileSync(path.join(root, ".docker", "config.json"), "private\n"); writeFileSync(path.join(root, "safe.ts"), "export {};\n"); + assert.deepEqual(collectSourceManifest(root).entries.map((entry) => entry.path), ["safe.ts"]); + writeFileSync(path.join(root, "leak.txt"), `-----BEGIN PRIVATE KEY-----\n${"A".repeat(120)}\n-----END PRIVATE KEY-----\n`); + assert.throws(() => collectSourceManifest(root), /credential-shaped content/u); + } finally { rmSync(root, { force: true, recursive: true }); } +}); + +test("build-source rejects credential stores without excluding credential-related source files", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-build-source-secret-")); + try { + for (const name of ["auth.go", "cookies.go", "keyring.go", "session.go", "token.go", "secret.go", "token_file.go"]) writeFileSync(path.join(root, name), "package fixture\n"); + for (const name of ["auth.json", "cookies", "cookies.json", "keyring.yaml", "session", "session.db", "token", "token.txt", "service-token.json", "secret", "deploy-secret.toml", "credentials.json"]) writeFileSync(path.join(root, name), "not archived\n"); + assert.deepEqual(collectSourceManifest(root, "build-source").entries.map((entry) => entry.path), ["auth.go", "cookies.go", "keyring.go", "secret.go", "session.go", "token.go", "token_file.go"]); + } finally { rmSync(root, { force: true, recursive: true }); } +}); + +test("build-source rejects opaque provider and credential-store directory trees", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-build-source-secret-dirs-")); + try { + for (const directory of [".codex", ".grok", ".config/gcloud", "gcloud", "credentials", "cookies", "keyrings", "sessions", "tokens", "secrets"]) { + mkdirSync(path.join(root, directory), { recursive: true }); + writeFileSync(path.join(root, directory, "opaque"), "unrecognizable credential bytes\n"); + } + mkdirSync(path.join(root, "internal", "auth"), { recursive: true }); + writeFileSync(path.join(root, "internal", "auth", "auth.go"), "package auth\n"); + assert.deepEqual(collectSourceManifest(root, "build-source").entries.map((entry) => entry.path), ["internal", "internal/auth", "internal/auth/auth.go"]); + } finally { rmSync(root, { force: true, recursive: true }); } +}); + +test("source bundle resolves bounded symlink chains and rejects cycles, dangling links, and ancestor escapes", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-links-")); + try { + writeFileSync(path.join(root, "target"), "ok\n"); symlinkSync("target", path.join(root, "second")); symlinkSync("second", path.join(root, "first")); + assert.doesNotThrow(() => collectSourceManifest(root)); + symlinkSync("cycle-b", path.join(root, "cycle-a")); symlinkSync("cycle-a", path.join(root, "cycle-b")); + assert.throws(() => collectSourceManifest(root), /cyclic/u); + rmSync(path.join(root, "cycle-a")); rmSync(path.join(root, "cycle-b")); symlinkSync("missing", path.join(root, "dangling")); + assert.throws(() => collectSourceManifest(root), /not an included input/u); + rmSync(path.join(root, "dangling")); symlinkSync("../outside", path.join(root, "escape")); + assert.throws(() => collectSourceManifest(root), /escapes its root/u); + } finally { rmSync(root, { force: true, recursive: true }); } +}); + +test("source bundle validates PAX long paths and rejects drift during creation", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-pax-")), archive = path.join(root, "..", `${path.basename(root)}.tar`); + try { + const directory = path.join(root, "a".repeat(90)); mkdirSync(directory); writeFileSync(path.join(directory, `${"b".repeat(120)}.ts`), "long\n"); + assert.doesNotThrow(() => validateSourceBundle(readFileSync((createSourceBundle(root, archive), archive)))); + writeFileSync(path.join(root, "drift.ts"), "before\n"); + assert.throws(() => createSourceBundle(root, archive, "source", { afterRead: () => writeFileSync(path.join(root, "drift.ts"), "after\n") }), /drifted/u); + } finally { rmSync(root, { force: true, recursive: true }); rmSync(archive, { force: true }); } +}); + +test("dependency bundle binds its amd64 lock closure and rejects a hostile tar", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-dependency-bundle-")), archive = path.join(root, "..", `${path.basename(root)}.tar`); + try { + mkdirSync(path.join(root, "npm-cache")); writeFileSync(path.join(root, "npm-cache", "content"), "cache\n"); writeFileSync(path.join(root, "package.json"), '{"name":"closure"}\n'); + writeFileSync(path.join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, name: "closure", packages: { "": {}, "node_modules/@openai/codex": { version: "1.0.0", integrity: `sha512-${"A".repeat(86)}==` }, "node_modules/typescript": { version: "5.0.0", integrity: `sha512-${"B".repeat(86)}==` } } })); + const verified = validateSourceBundle(readFileSync((createSourceBundle(root, archive, "dependencies"), archive))); + assert.equal(verified.manifest.dependency_lock.target, "linux/amd64"); + assert.equal(verified.manifest.dependency_lock.packages.length, 2); + const hostile = readFileSync(archive); hostile.write("../escape", 0, "utf8"); + assert.throws(() => validateSourceBundle(hostile), /checksum|unsafe/u); + } finally { rmSync(root, { force: true, recursive: true }); rmSync(archive, { force: true }); } +}); + +test("dependency lock truth rejects near-empty graphs and a fake Codex version", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-dependency-lock-")); + try { + mkdirSync(path.join(root, "npm-cache")); writeFileSync(path.join(root, "npm-cache", "content"), "cache"); writeFileSync(path.join(root, "package.json"), '{"name":"closure"}'); + writeFileSync(path.join(root, "package-lock.json"), '{"lockfileVersion":3,"packages":{}}'); + assert.throws(() => collectSourceManifest(root, "dependencies"), /lacks pinned/u); + writeFileSync(path.join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "": {}, "node_modules/@openai/codex": { version: "9.9.9" }, "node_modules/typescript": { version: "5.0.0", integrity: `sha512-${"B".repeat(86)}==` } } })); + assert.throws(() => collectSourceManifest(root, "dependencies"), /lacks immutable version\/integrity/u); + } finally { rmSync(root, { force: true, recursive: true }); } +}); + +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); + 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}"'); + + assert.ok(archiveDownload >= 0); + assert.ok(archiveDownload < archiveVerification); + assert.ok(archiveVerification < archiveExtraction); + assert.ok(archiveExtraction < executableLookup); + assert.ok(executableLookup < executableInstall); + 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); + assert.match(dockerfile, /import "\.\.\/node_modules\/@openai\/codex\/bin\/codex\.js"/u); + assert.match(dockerfile, /import \{ runOrganizationRuntimeCli \} from "\.\.\/node_modules\/@noopolis\/daimon\/dist\/runtime\/cli\.js"/u); + assert.match(dockerfile, /await runOrganizationRuntimeCli\(process\.argv\.slice\(2\)\)/u); + assert.match(dockerfile, /fs\.realpathSync\(p\)/u); + assert.match(dockerfile, /runtime link escape/u); + assert.match(dockerfile, /if\(raw!==c\(m\)\+"\\n"\|\|/u); + assert.match(dockerfile, /e=\{codex:/u); + assert.doesNotMatch(dockerfile, /e=\{agy:/u); + assert.match(dockerfile, /a\.unlockSourceSlot!=="agy-unlock-secret"/u); + assert.match(dockerfile, /a\.directoryMode!==448\|\|a\.fileMode!==384\|\|a\.maxUnlockBytes!==4096/u); + 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); }); diff --git a/scripts/build-local-moltnet.mjs b/scripts/build-local-moltnet.mjs index fe9b87f3..2fc7b193 100644 --- a/scripts/build-local-moltnet.mjs +++ b/scripts/build-local-moltnet.mjs @@ -8,13 +8,15 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSy import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { validateSourceBundle } from "./source-provenance-bundle.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const configuredMoltnetSource = process.env.SPAWNFILE_MOLTNET_SOURCE_DIR?.trim(); const moltnetDir = configuredMoltnetSource ? path.resolve(configuredMoltnetSource) : path.resolve(repoRoot, "..", "moltnet"); -const releaseDir = path.join(moltnetDir, "dist", "spawnfile-local-release"); +const configuredReleaseOutput = process.env.SPAWNFILE_MOLTNET_LOCAL_RELEASE_OUTPUT?.trim(); +const releaseDir = configuredReleaseOutput ? path.resolve(configuredReleaseOutput) : path.join(moltnetDir, "dist", "spawnfile-local-release"); const sha256 = (value) => createHash("sha256").update(value).digest("hex"); export const goarchForHost = () => { @@ -93,18 +95,25 @@ const assertCleanSource = (root) => { if (status.trim()) throw new Error("Local Moltnet build requires a clean source tree"); }; +export const createCapabilityProbeConfig = (kind, receiptStorePath) => ({ + version: "moltnet.node.v1", + moltnet: { base_url: "http://127.0.0.1:9", network_id: "capability" }, + attachments: [{ agent: { id: `${kind}-agent`, name: `${kind} agent` }, runtime: kind === "daimon" + ? { kind, control_url: "http://127.0.0.1:19700", receipt_store_path: receiptStorePath, token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" } + : { kind, control_url: "http://127.0.0.1:19690/agents/pi-agent/wake" } }] +}); + const assertBuiltBinaryCapabilities = (binaryPath) => { const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-capability-")); try { for (const kind of ["pi", "daimon"]) { const configPath = path.join(temporaryDirectory, `${kind}.json`); - writeFileSync(configPath, JSON.stringify({ - version: "moltnet.node.v1", - moltnet: { base_url: "http://127.0.0.1:9", network_id: "capability" }, - attachments: [{ agent: { id: `${kind}-agent`, name: `${kind} agent` }, runtime: kind === "daimon" - ? { kind, control_url: "http://127.0.0.1:19700", token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" } - : { kind, control_url: "http://127.0.0.1:19690/agents/pi-agent/wake" } }] - })); + const receiptDirectory = path.join(temporaryDirectory, "daimon-receipts"); + mkdirSync(receiptDirectory, { mode: 0o700, recursive: true }); + writeFileSync(configPath, JSON.stringify(createCapabilityProbeConfig( + kind, + path.join(receiptDirectory, `${kind}-agent.json`) + ))); const result = spawnSync(binaryPath, ["node", configPath], { encoding: "utf8", env: { ...process.env, SPAWNFILE_DAIMON_CONTROL_TOKEN: "local-capability-probe" }, timeout: 1_000 }); @@ -121,25 +130,49 @@ const assertBuiltBinaryCapabilities = (binaryPath) => { } }; +const assertDockerBinaryCapabilities = (binaryPath) => { + for (const kind of ["pi", "daimon"]) { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-probe-")), configPath = path.join(temporaryDirectory, "config.json"), receiptDirectory = path.join(temporaryDirectory, "receipts"); mkdirSync(receiptDirectory); + writeFileSync(configPath, JSON.stringify(createCapabilityProbeConfig(kind, "/receipts/agent.json"))); + const id = execFileSync("docker", ["create", "--platform", "linux/amd64", "--env", "SPAWNFILE_DAIMON_CONTROL_TOKEN=probe", "node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df", "timeout", "2", "/moltnet", "node", "/config.json"], { encoding: "utf8" }).trim(); + try { execFileSync("docker", ["cp", binaryPath, `${id}:/moltnet`]); execFileSync("docker", ["cp", configPath, `${id}:/config.json`]); execFileSync("docker", ["cp", receiptDirectory, `${id}:/receipts`]); const result = spawnSync("docker", ["start", "--attach", id], { encoding: "utf8" }); const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; if (result.status !== 124 && !/connection refused|connect:|dial tcp|network is unreachable/iu.test(output)) throw new Error(`Built Moltnet binary does not accept ${kind}-bridge: ${output.trim()}`); } + finally { execFileSync("docker", ["rm", "--force", id], { stdio: "ignore" }); rmSync(temporaryDirectory, { force: true, recursive: true }); } + } +}; + +const requiredBundle = (name, profile) => { + const value = process.env[name]?.trim(); if (!value || !path.isAbsolute(value)) throw new Error(`${name} must be an absolute provenance archive`); + const item = lstatSync(value); if (!item.isFile() || item.isSymbolicLink() || !item.size) throw new Error(`${name} must be a nonempty regular file`); + const receipt = validateSourceBundle(readFileSync(value)); if (receipt.manifest.exclude_policy.profile !== profile) throw new Error(`${name} has the wrong provenance profile`); return { ...receipt, path: value }; +}; + const main = () => { if (configuredMoltnetSource && !path.isAbsolute(configuredMoltnetSource)) { throw new Error("SPAWNFILE_MOLTNET_SOURCE_DIR must be absolute"); } - if (!existsSync(path.join(moltnetDir, ".git"))) throw new Error(`Missing sibling Moltnet checkout: ${moltnetDir}`); - assertCleanSource(moltnetDir); + if (configuredReleaseOutput && (!path.isAbsolute(configuredReleaseOutput) || path.resolve(configuredReleaseOutput) !== configuredReleaseOutput)) throw new Error("SPAWNFILE_MOLTNET_LOCAL_RELEASE_OUTPUT must be normalized absolute"); + const archiveMode = Boolean(process.env.SPAWNFILE_MOLTNET_SOURCE_BUNDLE || process.env.SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE); + if (!archiveMode && !existsSync(path.join(moltnetDir, ".git"))) throw new Error(`Missing sibling Moltnet checkout: ${moltnetDir}`); + if (!archiveMode) assertCleanSource(moltnetDir); const arch = goarchForTarget(); - if (arch !== goarchForHost()) throw new Error("Cross-compiled local archives cannot prove their binary capabilities on this host"); + if (!archiveMode && arch !== goarchForHost()) throw new Error("Cross-compiled local archives cannot prove their binary capabilities on this host"); const workDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-build-")); const binaryPath = path.join(workDirectory, "moltnet"); const asset = `moltnet_linux_${arch}.tar.gz`; const assetPath = path.join(releaseDir, asset); try { - execFileSync("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", binaryPath, "./cmd/moltnet"], { + if (archiveMode) { + if (arch !== "amd64") throw new Error("Moltnet archive provenance supports only linux/amd64"); + const source = requiredBundle("SPAWNFILE_MOLTNET_SOURCE_BUNDLE", "build-source"), dependencies = requiredBundle("SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE", "go-dependencies"), sourceContext = path.join(workDirectory, "source"), dependencyContext = path.join(workDirectory, "dependencies"), output = path.join(workDirectory, "output"); + mkdirSync(sourceContext); mkdirSync(dependencyContext); mkdirSync(output); writeFileSync(path.join(sourceContext, "source.tar"), readFileSync(source.path)); writeFileSync(path.join(dependencyContext, "dependencies.tar"), readFileSync(dependencies.path)); + execFileSync("docker", ["build", "--network=none", "--platform", `linux/${goarchForHost()}`, "--build-context", `source_bundle=${sourceContext}`, "--build-context", `dependency_bundle=${dependencyContext}`, "--output", `type=local,dest=${output}`, "--build-arg", `SOURCE_ARCHIVE_SHA256=${source.archive_sha256}`, "--build-arg", `DEPENDENCY_ARCHIVE_SHA256=${dependencies.archive_sha256}`, "-f", path.join(repoRoot, "runtime-images", "moltnet", "SourceBundle.Dockerfile"), repoRoot], { stdio: "inherit" }); + writeFileSync(binaryPath, readFileSync(path.join(output, "moltnet")), { mode: 0o755 }); assertDockerBinaryCapabilities(binaryPath); + } else execFileSync("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", binaryPath, "./cmd/moltnet"], { cwd: moltnetDir, env: { ...process.env, CGO_ENABLED: "0", GOARCH: arch, GOOS: "linux", GOTOOLCHAIN: "local" }, stdio: "inherit" }); - assertBuiltBinaryCapabilities(binaryPath); + if (!archiveMode) assertBuiltBinaryCapabilities(binaryPath); mkdirSync(releaseDir, { recursive: true }); execFileSync("tar", ["-C", workDirectory, "-czf", assetPath, "moltnet"], { stdio: "inherit" }); } finally { @@ -149,7 +182,8 @@ const main = () => { arch, asset, capabilities: ["daimon-bridge", "pi-bridge"], development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, sha256: sha256(readFileSync(assetPath)), - source_sha256: hashTrackedSourceEntries(moltnetDir, readTrackedEntries(moltnetDir)), + source_sha256: archiveMode ? validateSourceBundle(readFileSync(process.env.SPAWNFILE_MOLTNET_SOURCE_BUNDLE)).archive_sha256 : hashTrackedSourceEntries(moltnetDir, readTrackedEntries(moltnetDir)), + ...(archiveMode ? { source_inputs: { dependencies_sha256: validateSourceBundle(readFileSync(process.env.SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE)).archive_sha256, mode: "source-bundle", source_sha256: validateSourceBundle(readFileSync(process.env.SPAWNFILE_MOLTNET_SOURCE_BUNDLE)).archive_sha256, toolchain: "golang:1.24-bookworm@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac" } } : {}), stamp_version: "spawnfile.local-moltnet-release-stamp.v1" }; writeFileSync(path.join(releaseDir, `local_moltnet_release_stamp_${arch}.json`), `${JSON.stringify(stamp)}\n`); diff --git a/scripts/build-local-moltnet.test.mjs b/scripts/build-local-moltnet.test.mjs index 29a64adc..d967db69 100644 --- a/scripts/build-local-moltnet.test.mjs +++ b/scripts/build-local-moltnet.test.mjs @@ -4,7 +4,19 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { hashTrackedSourceEntries } from "./build-local-moltnet.mjs"; +import { createCapabilityProbeConfig, hashTrackedSourceEntries } from "./build-local-moltnet.mjs"; + +test("Daimon capability probe represents the complete required runtime contract", () => { + const receiptStorePath = "/var/lib/spawnfile/moltnet/networks/capability/daimon-receipts/daimon-agent.json"; + const config = createCapabilityProbeConfig("daimon", receiptStorePath); + assert.deepEqual(config.attachments[0].runtime, { + kind: "daimon", + control_url: "http://127.0.0.1:19700", + receipt_store_path: receiptStorePath, + token_env: "SPAWNFILE_DAIMON_CONTROL_TOKEN" + }); + assert.equal(path.isAbsolute(config.attachments[0].runtime.receipt_store_path), true); +}); test("source hashing accepts contained CLAUDE symlinks deterministically", () => { const root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-source-hash-")); diff --git a/scripts/create-linux-amd64-dependency-closure.mjs b/scripts/create-linux-amd64-dependency-closure.mjs new file mode 100644 index 00000000..3f69cca7 --- /dev/null +++ b/scripts/create-linux-amd64-dependency-closure.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const NODE_IMAGE = "node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df"; +const fail = (message) => { throw new Error(message); }; +const absolute = (value, label) => { + if (!value || !path.isAbsolute(value) || path.resolve(value) !== value) fail(`${label} must be a normalized absolute path`); + return value; +}; + +export const validateClosureProject = (root) => { + const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + const lock = JSON.parse(readFileSync(path.join(root, "package-lock.json"), "utf8")); + if (lock.lockfileVersion !== 3 || !lock.packages?.[""]) fail("dependency closure requires package-lock v3"); + const codex = lock.packages["node_modules/@openai/codex"]; + if (!codex || typeof codex.version !== "string" || !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(codex.integrity ?? "")) fail("dependency closure requires integrity-pinned @openai/codex"); + if (!lock.packages["node_modules/typescript"]) fail("dependency closure requires locked TypeScript"); + const declared = { ...packageJson.dependencies, ...packageJson.devDependencies }["@openai/codex"]; + if (!declared || /^(?:\^|~|>|<|\*|latest)/u.test(declared)) fail("@openai/codex must be exactly pinned"); + return { codex_version: codex.version }; +}; + +const normalizeCacheIndex = (root) => { + const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); if (entry.isDirectory()) visit(target); else if (entry.isFile()) { + const lines = readFileSync(target, "utf8").trim().split("\n").filter(Boolean).map((line) => { + const tab = line.indexOf("\t"), value = JSON.parse(line.slice(tab + 1)); value.time = 0; + const json = JSON.stringify(value); return `${createHash("sha1").update(json).digest("hex")}\t${json}`; + }); writeFileSync(target, `${lines.join("\n")}\n`); + } + } }; + visit(root); +}; + +const hydrateLockIntegrities = (root) => { + const byUrl = new Map(); const visit = (directory) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); if (entry.isDirectory()) visit(target); else if (entry.isFile()) for (const line of readFileSync(target, "utf8").trim().split("\n").filter(Boolean)) { + const value = JSON.parse(line.slice(line.indexOf("\t") + 1)); if (typeof value.key === "string" && typeof value.integrity === "string") byUrl.set(value.key.replace(/^make-fetch-happen:request-cache:/u, ""), value.integrity); + } + } }; visit(path.join(root, "npm-cache", "_cacache", "index-v5")); + const lockPath = path.join(root, "package-lock.json"), lock = JSON.parse(readFileSync(lockPath, "utf8")); + for (const [key, entry] of Object.entries(lock.packages ?? {})) if (key.startsWith("node_modules/") && !entry.integrity) { + const integrity = byUrl.get(entry.resolved); if (!integrity) fail(`npm cache lacks immutable content identity for ${key.slice(13)}`); entry.integrity = integrity; + } + writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); +}; + +export const createLinuxAmd64Closure = (input, output, codexVersion) => { + const source = absolute(input, "input"); absolute(output, "output"); + if (!lstatSync(source).isDirectory() || lstatSync(source).isSymbolicLink()) fail("input must be a real directory"); + if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(codexVersion ?? "")) fail("Codex version must be an exact semver"); + if (existsSync(output) && readdirSync(output).length) fail("output must be absent or empty"); + const sourceLock = JSON.parse(readFileSync(path.join(source, "package-lock.json"), "utf8")); + if (sourceLock.lockfileVersion !== 3 || !sourceLock.packages?.["node_modules/typescript"]) fail("source requires a package-lock v3 graph with TypeScript"); + mkdirSync(output, { recursive: true }); const staging = mkdtempSync(path.join(os.tmpdir(), "spawnfile-closure-project-")); + copyFileSync(path.join(source, "package.json"), path.join(staging, "package.json")); copyFileSync(path.join(source, "package-lock.json"), path.join(staging, "package-lock.json")); + for (const name of readdirSync(source).filter((entry) => entry.endsWith(".tgz")).sort()) copyFileSync(path.join(source, name), path.join(staging, name)); + const run = (network, inputDirectory, command, copyBack) => { + const id = execFileSync("docker", ["create", "--platform", "linux/amd64", ...(network ? ["--network", network] : []), NODE_IMAGE, "sh", "-ceu", command], { encoding: "utf8" }).trim(); + try { execFileSync("docker", ["cp", `${inputDirectory}/.`, `${id}:/closure`]); execFileSync("docker", ["start", "--attach", id], { stdio: "inherit" }); if (copyBack) execFileSync("docker", ["cp", `${id}:/closure/.`, output]); } + finally { execFileSync("docker", ["rm", "--force", id], { stdio: "ignore" }); } + }; + try { run(undefined, staging, `cd /closure; npm install --package-lock-only --ignore-scripts --save-exact @openai/codex@${codexVersion}; npm ci --ignore-scripts --cache /closure/npm-cache; npm ls --all; test \"$(node -p \"process.platform+'/'+process.arch\")\" = linux/x64; rm -rf node_modules`, true); } + finally { rmSync(staging, { force: true, recursive: true }); } + hydrateLockIntegrities(output); + const identity = validateClosureProject(output); if (identity.codex_version !== codexVersion) fail("prepared Codex version disagrees with the requested pin"); + rmSync(path.join(output, "npm-cache", "_logs"), { force: true, recursive: true }); rmSync(path.join(output, "npm-cache", "_update-notifier-last-checked"), { force: true }); + normalizeCacheIndex(path.join(output, "npm-cache", "_cacache", "index-v5")); + run("none", output, "cd /closure; npm ci --offline --ignore-scripts --cache /closure/npm-cache; npm ls --all; rm -rf node_modules", true); + rmSync(path.join(output, "npm-cache", "_logs"), { force: true, recursive: true }); rmSync(path.join(output, "npm-cache", "_update-notifier-last-checked"), { force: true }); + normalizeCacheIndex(path.join(output, "npm-cache", "_cacache", "index-v5")); + return { ...identity, image: NODE_IMAGE, target: "linux/amd64" }; +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 5) fail("usage: create-linux-amd64-dependency-closure "); + process.stdout.write(`${JSON.stringify(createLinuxAmd64Closure(process.argv[2], process.argv[3], process.argv[4]))}\n`); +} diff --git a/scripts/create-linux-amd64-go-closure.mjs b/scripts/create-linux-amd64-go-closure.mjs new file mode 100644 index 00000000..fb29c796 --- /dev/null +++ b/scripts/create-linux-amd64-go-closure.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const GO_IMAGE = "golang:1.24-bookworm@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac"; +const fail = (message) => { throw new Error(message); }; +const exact = (value, label) => { if (!value || !path.isAbsolute(value) || path.resolve(value) !== value) fail(`${label} must be normalized absolute`); return value; }; + +const run = (input, network, copyBack) => { + const id = execFileSync("docker", ["create", "--platform", "linux/amd64", ...(network ? ["--network", network] : []), "--env", "GOMODCACHE=/closure/gomodcache", "--env", "GOCACHE=/closure/gobuildcache", GO_IMAGE, "sh", "-ceu", "cd /closure; go mod download; go mod verify; test \"$(go env GOOS)/$(go env GOARCH)\" = linux/amd64; tar -cf /tmp/closure.tar ."], { encoding: "utf8" }).trim(); + try { execFileSync("docker", ["cp", `${input}/.`, `${id}:/closure`]); execFileSync("docker", ["start", "--attach", id], { stdio: "inherit" }); if (copyBack) { const archive = path.join(copyBack, ".closure-transfer.tar"); execFileSync("docker", ["cp", `${id}:/tmp/closure.tar`, archive]); execFileSync("tar", ["-xf", archive, "-C", copyBack]); rmSync(archive); execFileSync("chmod", ["-R", "u+rwX", copyBack]); } } + finally { execFileSync("docker", ["rm", "--force", id], { stdio: "ignore" }); } +}; + +export const createLinuxAmd64GoClosure = (sourcePath, outputPath) => { + const source = exact(sourcePath, "source"), output = exact(outputPath, "output"); + if (!lstatSync(source).isDirectory() || lstatSync(source).isSymbolicLink()) fail("source must be a real directory"); + if (existsSync(output) && readdirSync(output).length) fail("output must be absent or empty"); mkdirSync(output, { recursive: true }); + const staging = mkdtempSync(path.join(os.tmpdir(), "spawnfile-go-closure-")); + try { copyFileSync(path.join(source, "go.mod"), path.join(staging, "go.mod")); copyFileSync(path.join(source, "go.sum"), path.join(staging, "go.sum")); run(staging, undefined, output); run(output, "none"); } + finally { rmSync(staging, { force: true, recursive: true }); } + rmSync(path.join(output, "gobuildcache"), { force: true, recursive: true }); + return { image: GO_IMAGE, target: "linux/amd64" }; +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 4) fail("usage: create-linux-amd64-go-closure "); + process.stdout.write(`${JSON.stringify(createLinuxAmd64GoClosure(process.argv[2], process.argv[3]))}\n`); +} diff --git a/scripts/create-source-provenance-bundle.mjs b/scripts/create-source-provenance-bundle.mjs new file mode 100644 index 00000000..00e66baf --- /dev/null +++ b/scripts/create-source-provenance-bundle.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createSourceBundle } from "./source-provenance-bundle.mjs"; + +export const main = (argv = process.argv.slice(2)) => { + const profile = argv[0] === "--dependencies" ? "dependencies" : argv[0] === "--go-dependencies" ? "go-dependencies" : argv[0] === "--build-source" ? "build-source" : "source", values = profile === "source" ? argv : argv.slice(1); + if (values.length !== 2 || !values.every(path.isAbsolute)) throw new Error("usage: create-source-provenance-bundle [--build-source|--dependencies|--go-dependencies] "); + const receipt = createSourceBundle(values[0], values[1], profile); + process.stdout.write(`${JSON.stringify({ ...receipt, manifest: undefined, source_archive: values[1], version: "spawnfile.source-provenance-bundle-receipt.v1" })}\n`); +}; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(); diff --git a/scripts/moltnet-source-provenance.integration.test.mjs b/scripts/moltnet-source-provenance.integration.test.mjs new file mode 100644 index 00000000..671d52e4 --- /dev/null +++ b/scripts/moltnet-source-provenance.integration.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), moltnet = path.resolve(process.env.SPAWNFILE_TEST_MOLTNET_SOURCE ?? path.resolve(repository, "..", "moltnet")); + +test("literal dirty-tree Moltnet archive wrapper emits an amd64 provenance-bound consumable release", { timeout: 360_000 }, () => { + execFileSync("docker", ["version"], { stdio: "ignore" }); const temporary = mkdtempSync(path.join(repository, ".spawnfile-moltnet-docker-")); + try { + const closure = path.join(temporary, "go-closure"), sourceTar = path.join(temporary, "source.tar"), dependencyTar = path.join(temporary, "dependencies.tar"), release = path.join(temporary, "release"); mkdirSync(release); + execFileSync("npm", ["run", "--silent", "prepare:linux-amd64-go-closure", "--", moltnet, closure], { cwd: repository, stdio: "inherit" }); + execFileSync("npm", ["run", "--silent", "bundle:source-provenance", "--", "--build-source", moltnet, sourceTar], { cwd: repository, stdio: "ignore" }); + execFileSync("npm", ["run", "--silent", "bundle:source-provenance", "--", "--go-dependencies", closure, dependencyTar], { cwd: repository, stdio: "inherit" }); + execFileSync("npm", ["run", "--silent", "build:local-moltnet"], { cwd: repository, env: { ...process.env, MOLTNET_TARGET_GOARCH: "amd64", SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE: dependencyTar, SPAWNFILE_MOLTNET_LOCAL_RELEASE_OUTPUT: release, SPAWNFILE_MOLTNET_SOURCE_BUNDLE: sourceTar, SPAWNFILE_MOLTNET_SOURCE_DIR: moltnet }, stdio: "inherit" }); + const stamp = JSON.parse(readFileSync(path.join(release, "local_moltnet_release_stamp_amd64.json"), "utf8")); + assert.equal(stamp.arch, "amd64"); assert.equal(stamp.source_inputs.mode, "source-bundle"); assert.match(stamp.source_inputs.source_sha256, /^sha256:[a-f0-9]{64}$/u); assert.match(stamp.source_inputs.dependencies_sha256, /^sha256:[a-f0-9]{64}$/u); + assert.ok(readFileSync(path.join(release, stamp.asset)).length > 1_000_000); + execFileSync("node", ["--import", "tsx", "--input-type=module", "-e", `import {readLocalMoltnetReleaseIdentity as read} from './src/compiler/localMoltnetAuthority.ts'; const value=await read(${JSON.stringify(release)},'amd64',${JSON.stringify(process.arch === "arm64" ? "arm64" : "amd64")}); if(value.source_inputs?.source_sha256!==value.source_sha256) process.exit(2);`], { cwd: repository, stdio: "inherit" }); + } finally { rmSync(temporary, { force: true, recursive: true }); } +}); diff --git a/scripts/native-helper-artifacts.mjs b/scripts/native-helper-artifacts.mjs new file mode 100644 index 00000000..5e19d89f --- /dev/null +++ b/scripts/native-helper-artifacts.mjs @@ -0,0 +1,16 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +const expectedMachine = { x64: 62, arm64: 183 }; +export const verifyNativeHelperArtifacts = async (root) => { + for (const architecture of Object.keys(expectedMachine)) { + const binaryPath = path.join(root, `rename-noreplace-${architecture}`); const provenancePath = `${binaryPath}.provenance.json`; + let binary; let metadata; + try { [binary, metadata] = await Promise.all([readFile(binaryPath), lstat(binaryPath)]); } catch { throw new Error(`Missing Linux ${architecture} rename-noreplace helper`); } + if (!metadata.isFile() || metadata.isSymbolicLink() || !(metadata.mode & 0o111) || binary.subarray(0, 4).toString("hex") !== "7f454c46" || binary.readUInt16LE(18) !== expectedMachine[architecture]) throw new Error(`Wrong-architecture or unsafe Linux ${architecture} rename-noreplace helper`); + let provenance; try { provenance = JSON.parse(await readFile(provenancePath, "utf8")); } catch { throw new Error(`Missing Linux ${architecture} rename-noreplace provenance`); } + const digest = `sha256:${createHash("sha256").update(binary).digest("hex")}`; + if (provenance?.version !== "spawnfile.rename-noreplace-build.v1" || provenance.architecture !== architecture || provenance.target !== `linux/${architecture === "x64" ? "amd64" : "arm64"}` || provenance.binary_sha256 !== digest || provenance.builder_image !== "gcc:14.2.0@sha256:b99b86a28812b1e6453a231a947dc43d76fe192788a12f344a9b568bf9f5d24c" || provenance.compiler !== "gcc:14.2.0" || !/^sha256:[a-f0-9]{64}$/u.test(provenance.source_sha256)) throw new Error(`Invalid Linux ${architecture} rename-noreplace provenance`); + } +}; diff --git a/scripts/native-helper-artifacts.test.mjs b/scripts/native-helper-artifacts.test.mjs new file mode 100644 index 00000000..1581bdef --- /dev/null +++ b/scripts/native-helper-artifacts.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { chmod, cp, mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { verifyNativeHelperArtifacts } from "./native-helper-artifacts.mjs"; + +test("native helper closure rejects missing and wrong-architecture artifacts", async () => { + await verifyNativeHelperArtifacts(path.resolve("src/deployment/native/artifacts")); + const empty = await mkdtemp(path.join(os.tmpdir(), "spawnfile-native-empty-")); + await assert.rejects(verifyNativeHelperArtifacts(empty), /Missing Linux x64/u); + const built = path.resolve("dist/deployment/native"); const wrong = await mkdtemp(path.join(os.tmpdir(), "spawnfile-native-wrong-")); + await cp(built, wrong, { recursive: true }); await writeFile(path.join(wrong, "rename-noreplace-x64"), "not-elf"); await chmod(path.join(wrong, "rename-noreplace-x64"), 0o755); + await assert.rejects(verifyNativeHelperArtifacts(wrong), /Wrong-architecture/u); +}); diff --git a/scripts/native-helper-integration.test.mjs b/scripts/native-helper-integration.test.mjs new file mode 100644 index 00000000..f96d3f29 --- /dev/null +++ b/scripts/native-helper-integration.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); +const helpers = path.resolve("dist/deployment/native"); +const docker = async (args) => await execFile("docker", args); + +for (const architecture of ["x64", "arm64"]) test(`real Linux ${architecture} helper atomically activates and preserves EEXIST destinations`, async () => { + const platform = architecture === "x64" ? "amd64" : "arm64"; const container = `spawnfile-helper-test-${architecture}-${process.pid}`; + try { + await docker(["create", "--name", container, "--platform", `linux/${platform}`, "alpine:3.22", "sleep", "300"]); await docker(["start", container]); + await docker(["cp", path.join(helpers, `rename-noreplace-${architecture}`), `${container}:/rename-noreplace`]); + await docker(["exec", container, "mkdir", "-p", "/work/source-success"]); await docker(["exec", container, "sh", "-c", "printf 'move\\n' > /work/source-success/data"]); + assert.equal((await docker(["exec", container, "/rename-noreplace", "/work", "source-success", "destination-success"])).stdout, '{"ok":true}\n'); + assert.equal((await docker(["exec", container, "cat", "/work/destination-success/data"])).stdout, "move\n"); + for (const nonempty of [false, true]) { + const suffix = nonempty ? "nonempty" : "empty"; await docker(["exec", container, "mkdir", `/work/source-${suffix}`, `/work/destination-${suffix}`]); + if (nonempty) await docker(["exec", container, "sh", "-c", `printf 'keep\\n' > /work/destination-${suffix}/keep`]); + const inode = (await docker(["exec", container, "stat", "-c", "%i", `/work/destination-${suffix}`])).stdout; + await assert.rejects(docker(["exec", container, "/rename-noreplace", "/work", `source-${suffix}`, `destination-${suffix}`]), (error) => error.stdout === '{"ok":false,"error":"EEXIST","errno":17}\n'); + assert.equal((await docker(["exec", container, "stat", "-c", "%i", `/work/destination-${suffix}`])).stdout, inode); + if (nonempty) assert.equal((await docker(["exec", container, "cat", `/work/destination-${suffix}/keep`])).stdout, "keep\n"); + } + } finally { try { await docker(["rm", "--force", container]); } catch {} } +}); diff --git a/scripts/native-helper-workflows.test.mjs b/scripts/native-helper-workflows.test.mjs new file mode 100644 index 00000000..0a774a12 --- /dev/null +++ b/scripts/native-helper-workflows.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const gate = "node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.test.mjs"; + +test("PR/main and publish workflows explicitly configure QEMU and run native syscall gates", async () => { + for (const workflow of [".github/workflows/test.yml", ".github/workflows/publish.yml"]) { + const source = await readFile(workflow, "utf8"); + assert.match(source, /docker\/setup-qemu-action@v3/u); assert.match(source, /platforms: amd64,arm64/u); assert.match(source, /npm run build:native/u); assert.ok(source.includes(gate)); + } +}); + +test("native helper build uses only the pinned compiler image", async () => { + const dockerfile = await readFile("src/deployment/native/Dockerfile", "utf8"); + assert.match(dockerfile, /^FROM gcc:14\.2\.0@sha256:[a-f0-9]{64} AS build$/mu); assert.match(dockerfile, /gcc -dumpfullversion/u); assert.doesNotMatch(dockerfile, /apk add|apt-get|dnf|yum/u); +}); + +test("normal package build copies shipped helpers without invoking the native rebuild", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")); + assert.match(packageJson.scripts.build, /copyArtifacts\.mjs/u); + assert.doesNotMatch(packageJson.scripts.build, /native\/build\.mjs|docker/u); + assert.equal(packageJson.scripts["build:native"], "node ./src/deployment/native/build.mjs"); +}); diff --git a/scripts/source-provenance-bundle.integration.test.mjs b/scripts/source-provenance-bundle.integration.test.mjs new file mode 100644 index 00000000..7879a3e7 --- /dev/null +++ b/scripts/source-provenance-bundle.integration.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createSourceBundle, validateSourceBundle } from "./source-provenance-bundle.mjs"; +import { renderRuntimeLinkMaterializer } from "../dist/compiler/containerRuntimeLinkMaterializer.js"; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const digest = (file) => `sha256:${execFileSync("shasum", ["-a", "256", file], { encoding: "utf8" }).split(" ")[0]}`; +const sha512 = (file) => `sha512:${createHash("sha512").update(readFileSync(file)).digest("hex")}`; + +test("actual Daimon lock produces a real offline linux/amd64 shipped artifact and rejects tampering", { timeout: 360_000 }, () => { + execFileSync("docker", ["version"], { stdio: "ignore" }); + const temporary = mkdtempSync(path.join(repository, ".spawnfile-source-docker-")); + let registry; + try { + const closure = path.join(temporary, "closure"), actualDaimon = path.resolve(repository, "..", "daimon"), daimonSource = path.join(temporary, "actual-daimon-input"); + mkdirSync(daimonSource); cpSync(path.join(actualDaimon, "package.json"), path.join(daimonSource, "package.json")); cpSync(path.join(actualDaimon, "package-lock.json"), path.join(daimonSource, "package-lock.json")); + execFileSync("npm", ["run", "--silent", "prepare:linux-amd64-closure", "--", daimonSource, closure, "0.142.3"], { cwd: repository, stdio: "inherit" }); + const sourceTar = path.join(temporary, "source.tar"), dependencyTar = path.join(temporary, "dependencies.tar"); + execFileSync("npm", ["run", "--silent", "bundle:source-provenance", "--", actualDaimon, sourceTar], { cwd: repository, stdio: "ignore" }); + execFileSync("npm", ["run", "--silent", "bundle:source-provenance", "--", "--dependencies", closure, dependencyTar], { cwd: repository, stdio: "inherit" }); + const sourceReceipt = validateSourceBundle(readFileSync(sourceTar)), dependencyReceipt = validateSourceBundle(readFileSync(dependencyTar)); + const sourceContext = path.join(temporary, "source-context"), dependencyContext = path.join(temporary, "dependency-context"), output = path.join(temporary, "output"); + mkdirSync(sourceContext); mkdirSync(dependencyContext); mkdirSync(output); cpSync(sourceTar, path.join(sourceContext, "source.tar")); cpSync(dependencyTar, path.join(dependencyContext, "dependencies.tar")); + const args = ["build", "--network=none", "--platform", "linux/amd64", "--build-context", `source_bundle=${sourceContext}`, "--build-context", `dependency_bundle=${dependencyContext}`, + "--output", `type=local,dest=${output}`, "--build-arg", `SOURCE_ARCHIVE_SHA256=${sourceReceipt.archive_sha256}`, "--build-arg", `DEPENDENCY_ARCHIVE_SHA256=${dependencyReceipt.archive_sha256}`, + "--build-arg", `SOURCE_MANIFEST_SHA256=${sourceReceipt.manifest_sha256}`, "--build-arg", `DEPENDENCY_MANIFEST_SHA256=${dependencyReceipt.manifest_sha256}`, + "-f", path.join(repository, "runtime-images", "daimon", "SourceBundle.Dockerfile"), repository]; + execFileSync("docker", args, { stdio: "inherit" }); + const identity = JSON.parse(readFileSync(path.join(output, "source-inputs.json"), "utf8")); + assert.equal(identity.target, "linux/amd64"); assert.equal(identity.source.archive_sha256, digest(sourceTar)); assert.equal(identity.dependencies.archive_sha256, digest(dependencyTar)); + assert.match(execFileSync("tar", ["-tzf", path.join(output, "daimon.tgz")], { encoding: "utf8" }), /package\/dist\/runtime\/contract-manifest\.json/u); + assert.match(execFileSync("tar", ["-tvzf", path.join(output, "daimon.tgz")], { encoding: "utf8" }), /-rwxr-xr-x[^\n]*package\/dist\/runtime\/native\/daimon-engine-broker/u); + const packedBroker = execFileSync("tar", ["-xOf", path.join(output, "daimon.tgz"), "package/dist/runtime/native/daimon-engine-broker"]); + assert.equal(`sha256:${createHash("sha256").update(packedBroker).digest("hex")}`, "sha256:e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd"); + const packageContext = path.join(temporary, "package-context"), probe = path.join(temporary, "probe"); mkdirSync(packageContext); mkdirSync(probe); + cpSync(path.join(output, "daimon.tgz"), path.join(packageContext, "daimon.tgz")); cpSync(path.join(output, "runtime-dependencies.tar"), path.join(packageContext, "dependencies.tar")); cpSync(path.join(output, "source-inputs.json"), path.join(packageContext, "source-inputs.json")); + execFileSync("docker", ["build", "--network=none", "--platform", "linux/amd64", "--target", "offline_dependency_probe", "--build-context", `daimon_package=${packageContext}`, + "--output", `type=local,dest=${probe}`, "--build-arg", `DAIMON_PACKAGE_SHA256=${digest(path.join(output, "daimon.tgz"))}`, + "--build-arg", `DAIMON_DEPENDENCY_ARCHIVE_SHA256=${digest(path.join(output, "runtime-dependencies.tar"))}`, "-f", path.join(repository, "runtime-images", "daimon", "Dockerfile"), repository], { stdio: "inherit" }); + assert.deepEqual(JSON.parse(readFileSync(path.join(probe, "probe", "source-inputs.json"), "utf8")), identity); + const grok = path.join(packageContext, "grok"), agyTree = path.join(temporary, "agy-tree"), agy = path.join(agyTree, "antigravity"), agyTar = path.join(packageContext, "agy.tar.gz"); + writeFileSync(grok, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); mkdirSync(agyTree); writeFileSync(agy, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + execFileSync("tar", ["-czf", agyTar, "-C", agyTree, "antigravity"]); + const runtimeArchive = digest(path.join(output, "runtime-dependencies.tar")); + const sourceInputs = { dependencies: { archive_sha256: dependencyReceipt.archive_sha256, manifest_sha256: dependencyReceipt.manifest_sha256, package_lock_sha256: dependencyReceipt.manifest.dependency_lock.package_lock_sha256, runtime_archive_sha256: runtimeArchive }, mode: "source-bundle", source: { archive_sha256: sourceReceipt.archive_sha256, manifest_sha256: sourceReceipt.manifest_sha256 }, version: "spawnfile.daimon-source-inputs.v1" }; + writeFileSync(path.join(packageContext, "source-inputs.json"), `${JSON.stringify(sourceInputs)}\n`); + const manifestBytes = execFileSync("tar", ["-xOf", path.join(output, "daimon.tgz"), "package/dist/runtime/contract-manifest.json"]), manifestSha = `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`; + const installed = path.join(temporary, "installed"); mkdirSync(installed); execFileSync("tar", ["-xf", path.join(output, "runtime-dependencies.tar"), "-C", installed]); + const codexSha = digest(path.join(installed, "@openai", "codex", "bin", "codex.js")), grokSha = digest(grok), agySha = digest(agy), packageSha = digest(path.join(output, "daimon.tgz")); + registry = execFileSync("docker", ["run", "--detach", "--publish", "127.0.0.1::5000", "registry:2"], { encoding: "utf8" }).trim(); + const mapped = execFileSync("docker", ["port", registry, "5000/tcp"], { encoding: "utf8" }).trim().split("\n")[0], port = mapped.slice(mapped.lastIndexOf(":") + 1); + const identityPath = path.join(repository, ".local-daimon-runtime-identity.json"), priorIdentity = existsSync(identityPath) ? readFileSync(identityPath) : null; + try { + execFileSync("npm", ["run", "--silent", "build:local-daimon"], { cwd: repository, env: { ...process.env, AGY_CLI_SHA256: agySha, AGY_CLI_SHA512: sha512(agyTar), AGY_CLI_URL: "https://invalid.example/agy", AGY_CLI_VERSION: "fixture", CODEX_CLI_SHA256: codexSha, GROK_CLI_SHA256: grokSha, GROK_CLI_URL: "https://invalid.example/grok", GROK_CLI_VERSION: "fixture", SPAWNFILE_AGY_CLI_ARCHIVE: agyTar, SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE: dependencyTar, SPAWNFILE_DAIMON_LOCAL_IMAGE_TAG: `127.0.0.1:${port}/noopolis/spawnfile-runtime-daimon:archive-wrapper`, SPAWNFILE_DAIMON_SOURCE_BUNDLE: sourceTar, SPAWNFILE_GROK_CLI_FILE: grok }, stdio: "ignore" }); + const wrapperIdentity = JSON.parse(readFileSync(identityPath, "utf8")); assert.equal(wrapperIdentity.image_architecture, "amd64"); assert.match(wrapperIdentity.image_reference, new RegExp(`^127\\.0\\.0\\.1:${port}/noopolis/spawnfile-runtime-daimon@sha256:[a-f0-9]{64}$`, "u")); + } finally { if (priorIdentity) writeFileSync(identityPath, priorIdentity); else rmSync(identityPath, { force: true }); } + const receipt = { architecture: "amd64", daimon: { package_sha256: packageSha, source_inputs: sourceInputs, source_sha256: digest(path.join(packageContext, "source-inputs.json")) }, engines: { agy: { executable_sha256: agySha }, codex: { executable_sha256: codexSha }, grok: { executable_sha256: grokSha } }, manifest_sha256: manifestSha, provenance: { agy: { archive: { format: "tar.gz", sha512: sha512(agyTar), url: "https://invalid.example/agy", version: "fixture" } }, grok: { executable: { sha256: grokSha, url: "https://invalid.example/grok", version: "fixture" } } }, version: "spawnfile.daimon-runtime-capability-receipt.v1" }; + const shipped = path.join(temporary, "shipped"); mkdirSync(shipped); + execFileSync("docker", ["build", "--network=none", "--platform", "linux/amd64", "--build-context", `daimon_package=${packageContext}`, "--output", `type=local,dest=${shipped}`, + "--build-arg", `DAIMON_CAPABILITY_RECEIPT_BASE64=${Buffer.from(`${JSON.stringify(receipt)}\n`).toString("base64")}`, "--build-arg", `DAIMON_MANIFEST_SHA256=${manifestSha}`, "--build-arg", `DAIMON_PACKAGE_SHA256=${packageSha}`, "--build-arg", `DAIMON_SOURCE_SHA256=${receipt.daimon.source_sha256}`, "--build-arg", "DAIMON_DEPENDENCY_MODE=offline-bundle", "--build-arg", `DAIMON_DEPENDENCY_ARCHIVE_SHA256=${runtimeArchive}`, "--build-arg", `CODEX_CLI_SHA256=${codexSha}`, "--build-arg", "GROK_CLI_VERSION=fixture", "--build-arg", "GROK_CLI_URL=https://invalid.example/grok", "--build-arg", `GROK_CLI_SHA256=${grokSha.slice(7)}`, "--build-arg", "AGY_CLI_VERSION=fixture", "--build-arg", "AGY_CLI_URL=https://invalid.example/agy", "--build-arg", `AGY_CLI_SHA512=${sha512(agyTar).slice(7)}`, "--build-arg", `AGY_CLI_SHA256=${agySha.slice(7)}`, "-f", path.join(repository, "runtime-images", "daimon", "Dockerfile"), repository], { stdio: "ignore" }); + assert.deepEqual(JSON.parse(readFileSync(path.join(shipped, "opt", "spawnfile", "runtime-installs", "daimon", "source-inputs.json"), "utf8")), receipt.daimon.source_inputs); + const shippedRoot=path.join(shipped,"opt","spawnfile","runtime-installs","daimon"); + assert.equal(digest(path.join(shippedRoot,"bin","daimon-engine-broker")),"sha256:e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd"); + assert.equal(readFileSync(path.join(shippedRoot,"contract-manifest.sha256"),"utf8"),`${manifestSha}\n`); + const orgContext = path.join(temporary, "literal-org-context"), orgTag = `spawnfile-literal-org-${Date.now().toString(36)}`; + mkdirSync(orgContext); + writeFileSync(path.join(orgContext, "materialize.cjs"), renderRuntimeLinkMaterializer()); + writeFileSync(path.join(orgContext, "entrypoint.sh"), [ + "#!/usr/bin/env bash", "set -euo pipefail", + "export HOME=/tmp XDG_CONFIG_HOME=/tmp/.config XDG_CACHE_HOME=/tmp/.cache", + "root=/opt/spawnfile/runtime-installs/daimon", + "test ! -L \"$root/bin/daimon-runtime\"", + "test ! -L \"$root/bin/codex\"", + "test \"$(stat -c '%u:%g:%a' \"$root/bin/daimon-runtime\")\" = 0:0:555", + "test \"$(stat -c '%u:%g:%a' \"$root/bin/codex\")\" = 0:0:555", + "! ls \"$root\" >/dev/null 2>&1", + "! test -w \"$root/bin/daimon-runtime\"", + "output=\"$(\"$root/bin/daimon-runtime\" 2>&1 || true)\"", + "grep -q 'usage: daimon-runtime' <<<\"$output\"", + "printf 'literal-runtime-ok uid=%s\\n' \"$(id -u)\"" + ].join("\n") + "\n", { mode: 0o755 }); + writeFileSync(path.join(orgContext, "Dockerfile"), [ + "FROM runtime_artifact AS runtime_artifact", + "FROM node:24-bookworm-slim", + "COPY --from=runtime_artifact /opt/spawnfile/runtime-installs/daimon /opt/spawnfile/runtime-installs/daimon", + "COPY materialize.cjs /opt/spawnfile/materialize-runtime-links.cjs", + "COPY --chmod=755 entrypoint.sh /entrypoint.sh", + "RUN node /opt/spawnfile/materialize-runtime-links.cjs /opt/spawnfile/runtime-installs/daimon && rm /opt/spawnfile/materialize-runtime-links.cjs && test -z \"$(find -P /opt/spawnfile/runtime-installs/daimon -type l -print -quit)\" && chmod 711 /opt /opt/spawnfile /opt/spawnfile/runtime-installs && find -P /opt/spawnfile/runtime-installs/daimon -type d -exec chmod 711 {} + && find -P /opt/spawnfile/runtime-installs/daimon -type f -perm /111 -exec chmod 555 {} + && find -P /opt/spawnfile/runtime-installs/daimon -type f ! -perm /111 -exec chmod 444 {} + && useradd --uid 2000 --no-create-home runtime", + "USER 2000:2000", "ENTRYPOINT [\"/entrypoint.sh\"]" + ].join("\n") + "\n"); + execFileSync("docker", ["build", "--network=none", "--platform", "linux/amd64", "--build-context", `runtime_artifact=${shipped}`, "--tag", orgTag, orgContext], { stdio: "ignore" }); + const orgContainer = `${orgTag}-container`; + try { + execFileSync("docker", ["create", "--name", orgContainer, orgTag], { stdio: "ignore" }); + assert.match(execFileSync("docker", ["start", "--attach", orgContainer], { encoding: "utf8" }), /literal-runtime-ok uid=2000/u); + assert.match(execFileSync("docker", ["start", "--attach", orgContainer], { encoding: "utf8" }), /literal-runtime-ok uid=2000/u); + } finally { + spawnSync("docker", ["rm", "--force", orgContainer], { stdio: "ignore" }); + spawnSync("docker", ["image", "rm", "--force", orgTag], { stdio: "ignore" }); + } + const falseLockPath = path.join(closure, "package-lock.json"), falseLock = JSON.parse(readFileSync(falseLockPath, "utf8")); + falseLock.packages["node_modules/@openai/codex"].integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; + writeFileSync(falseLockPath, JSON.stringify(falseLock)); + const fakeTar = path.join(temporary, "fake-dependencies.tar"), fakeContext = path.join(temporary, "fake-context"), fakeOutput = path.join(temporary, "fake-output"); + execFileSync("npm", ["run", "--silent", "bundle:source-provenance", "--", "--dependencies", closure, fakeTar], { cwd: repository, stdio: "ignore" }); + const fakeReceipt = validateSourceBundle(readFileSync(fakeTar)); + mkdirSync(fakeContext); mkdirSync(fakeOutput); cpSync(fakeTar, path.join(fakeContext, "dependencies.tar")); + const fakeArgs = args.map((value) => value === `dependency_bundle=${dependencyContext}` ? `dependency_bundle=${fakeContext}` : value === `DEPENDENCY_ARCHIVE_SHA256=${dependencyReceipt.archive_sha256}` ? `DEPENDENCY_ARCHIVE_SHA256=${fakeReceipt.archive_sha256}` : value === `DEPENDENCY_MANIFEST_SHA256=${dependencyReceipt.manifest_sha256}` ? `DEPENDENCY_MANIFEST_SHA256=${fakeReceipt.manifest_sha256}` : value === `type=local,dest=${output}` ? `type=local,dest=${fakeOutput}` : value); + assert.notEqual(spawnSync("docker", fakeArgs, { stdio: "ignore" }).status, 0); + for (const fault of ["missing", "wrong"]) { + const badRoot=path.join(temporary,`bad-native-${fault}`),badTar=path.join(temporary,`bad-native-${fault}.tar`),badContext=path.join(temporary,`bad-native-${fault}-context`),badOutput=path.join(temporary,`bad-native-${fault}-output`); + mkdirSync(badRoot);execFileSync("tar",["-xf",sourceTar,"-C",badRoot]);rmSync(path.join(badRoot,".spawnfile-source-manifest.json"),{force:true});const artifact=path.join(badRoot,"src","runtime","native","artifacts","daimon-engine-broker-x64");if(fault==="missing")rmSync(artifact);else writeFileSync(artifact,"wrong-native-artifact\n",{mode:0o755}); + const badReceipt=createSourceBundle(badRoot,badTar);mkdirSync(badContext);mkdirSync(badOutput);cpSync(badTar,path.join(badContext,"source.tar"));const badArgs=args.map((value)=>value===`source_bundle=${sourceContext}`?`source_bundle=${badContext}`:value===`SOURCE_ARCHIVE_SHA256=${sourceReceipt.archive_sha256}`?`SOURCE_ARCHIVE_SHA256=${badReceipt.archive_sha256}`:value===`SOURCE_MANIFEST_SHA256=${sourceReceipt.manifest_sha256}`?`SOURCE_MANIFEST_SHA256=${badReceipt.manifest_sha256}`:value===`type=local,dest=${output}`?`type=local,dest=${badOutput}`:value);assert.notEqual(spawnSync("docker",badArgs,{stdio:"ignore"}).status,0); + } + const tampered = readFileSync(path.join(dependencyContext, "dependencies.tar")); tampered[tampered.length - 1025] ^= 1; writeFileSync(path.join(dependencyContext, "dependencies.tar"), tampered); + assert.notEqual(spawnSync("docker", args, { stdio: "ignore" }).status, 0); + } finally { if (registry) spawnSync("docker", ["rm", "--force", registry], { stdio: "ignore" }); rmSync(temporary, { force: true, recursive: true }); } +}); diff --git a/scripts/source-provenance-bundle.mjs b/scripts/source-provenance-bundle.mjs new file mode 100644 index 00000000..623627bf --- /dev/null +++ b/scripts/source-provenance-bundle.mjs @@ -0,0 +1,214 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const sourceExcludedNames = new Set([".git", ".hg", ".svn", "node_modules", "dist", "dist-test-runtime", "coverage", ".cache", ".npm", ".runtime", ".spawn", ".spawn-dev"]); +const buildSourceExcludedNames = new Set([...sourceExcludedNames].filter((name) => name !== "dist")); +const dependencyExcludedNames = new Set([".git", ".hg", ".svn", ".cache", ".npm"]); +const credentialStoreExtension = String.raw`(?:json|ya?ml|toml|ini|conf|config|txt|db|sqlite3?|store|bak)`; +const secretName = new RegExp(String.raw`^(?:\.env(?:\..+)?|.*(?:credential|credentials|secret|secrets|token|tokens)(?:[-_.](?:auth|store|credentials?))?(?:\.${credentialStoreExtension})?)$`, "iu"); +const credentialFile = new RegExp(String.raw`^(?:\.npmrc|\.netrc|\.yarnrc(?:\.yml)?|(?:auth|cookies?|keyrings?|sessions?)(?:\.${credentialStoreExtension})?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\..*)?|.*\.(?:pem|key))$`, "iu"); +const credentialDirectory = new Set([".aws", ".codex", ".config", ".docker", ".gcloud", ".grok", ".ssh", "cookie", "cookies", "credential", "credentials", "gcloud", "keyring", "keyrings", "secrets", "sessions", "tokens"]); +const credentialContent = /(?:-----BEGIN ((?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY)-----\s+[A-Za-z0-9+/=\r\n]{80,}\s+-----END \1-----|AKIA(?!IOSFODNN7EXAMPLE)[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[pousr]_[0-9A-Za-z]{30,255}|github_pat_[0-9A-Za-z_]{40,255}|xox[baprs]-[0-9A-Za-z-]{20,255}|_authToken\s*[=:]\s*[0-9A-Za-z._~+\/-]{16,}|authorization\s*[=:]\s*["']Bearer\s+(?!should-not-survive)[0-9A-Za-z._~+\/-]{16,})/u; +const safeRelative = (value) => value && !path.isAbsolute(value) && !value.includes("\\") && value.split("/").every((part) => part && part !== "." && part !== ".."); + +const excluded = (relative, profile, isDirectory = false) => { + const parts = relative.split("/"); + if (profile === "go-dependencies" && relative.startsWith("gomodcache/")) return false; + const names = profile === "dependencies" || profile === "go-dependencies" ? dependencyExcludedNames : profile === "build-source" ? buildSourceExcludedNames : sourceExcludedNames; + return parts.some((part) => names.has(part) || credentialDirectory.has(part)) || (!isDirectory && (secretName.test(parts.at(-1)) || credentialFile.test(parts.at(-1)))) || parts.some((part) => part.endsWith("~")); +}; + +const assertNoCredentialContent = (relative, bytes) => { + if (credentialContent.test(bytes.toString("utf8"))) throw new Error(`Source input contains credential-shaped content: ${relative}`); +}; + +const assertSymlinkGraph = (entries) => { + const byPath = new Map(entries.map((entry) => [entry.path, entry])); + for (const origin of entries) if (origin.type === "symlink") { + let current = origin; const seen = new Set([origin.path]); + for (let depth = 0; depth < 40; depth += 1) { + const target = path.posix.normalize(path.posix.join(path.posix.dirname(current.path), current.link)); + if (!safeRelative(target) || seen.has(target)) throw new Error(`Source symlink chain is cyclic or escapes its root: ${origin.path}`); + const next = byPath.get(target); if (!next) throw new Error(`Source symlink target is not an included input: ${origin.path}`); + if (next.type === "file" || next.type === "directory") { current = undefined; break; } + seen.add(target); current = next; + } + if (current) throw new Error(`Source symlink chain exceeds its bound: ${origin.path}`); + } +}; + +export const collectSourceManifest = (root, profile = "source") => { + if (!["source", "build-source", "dependencies", "go-dependencies"].includes(profile)) throw new Error("Unknown provenance bundle profile"); + const canonicalRoot = realpathSync(root); + const entries = []; + const visit = (relative) => { + const absolute = path.join(canonicalRoot, relative); + for (const name of readdirSync(absolute).sort()) { + const child = relative ? `${relative}/${name}` : name; + const item = lstatSync(path.join(canonicalRoot, child)); + if (excluded(child, profile, item.isDirectory())) continue; + if (!safeRelative(child)) throw new Error("Source bundle contains an unsafe path"); + if (item.isDirectory()) { entries.push({ path: child, mode: item.mode & 0o111 ? 0o755 : 0o700, type: "directory" }); visit(child); continue; } + if (item.isSymbolicLink()) { + const link = readlinkSync(path.join(canonicalRoot, child)); + if (!link || path.isAbsolute(link)) throw new Error(`Source symlink escapes its root: ${child}`); + const target = path.resolve(path.dirname(path.join(canonicalRoot, child)), link); + const targetRelative = path.relative(canonicalRoot, target); + if (!safeRelative(targetRelative.split(path.sep).join("/"))) throw new Error(`Source symlink escapes its root: ${child}`); + let targetItem; try { targetItem = lstatSync(target); } catch { throw new Error(`Source symlink target is not an included input: ${child}`); } + if (excluded(targetRelative, profile, targetItem.isDirectory())) throw new Error(`Source symlink target is not an included input: ${child}`); + entries.push({ path: child, link, mode: item.mode & 0o777, type: "symlink" }); + continue; + } + if (!item.isFile()) throw new Error(`Source bundle contains an unsupported entry: ${child}`); + const bytes = readFileSync(path.join(canonicalRoot, child)); + if (profile !== "go-dependencies" || !child.startsWith("gomodcache/")) assertNoCredentialContent(child, bytes); + entries.push({ path: child, mode: item.mode & 0o111 ? 0o755 : 0o644, sha256: `sha256:${sha256(bytes)}`, size: bytes.length, type: "file" }); + } + }; + visit(""); + if (!entries.length) throw new Error("Source bundle is empty"); + assertSymlinkGraph(entries); + const names = profile === "dependencies" || profile === "go-dependencies" ? dependencyExcludedNames : profile === "build-source" ? buildSourceExcludedNames : sourceExcludedNames; + let dependency_lock; + if (profile === "dependencies") { + const lockPath = path.join(canonicalRoot, "package-lock.json"), lock = readFileSync(lockPath); + assertNoCredentialContent("package-lock.json", lock); + let parsedLock; try { parsedLock = JSON.parse(lock.toString("utf8")); } catch { throw new Error("Dependency closure package-lock.json is invalid JSON"); } + if (parsedLock.lockfileVersion !== 3 || !parsedLock.packages || typeof parsedLock.packages !== "object") throw new Error("Dependency closure requires package-lock v3 package graph truth"); + const required = ["npm-cache", "package-lock.json", "package.json"]; + const included = new Set(entries.map((entry) => entry.path)); + if (required.some((entry) => !included.has(entry))) throw new Error("Dependency bundle lacks the required lock-backed amd64 build/runtime closure"); + const packages = Object.entries(parsedLock.packages).filter(([key]) => key.startsWith("node_modules/")).map(([key, lockEntry]) => { + if (typeof lockEntry.version !== "string" || !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(lockEntry.integrity ?? "")) throw new Error(`Package-lock dependency lacks immutable version/integrity: ${key.slice(13)}`); + return { integrity: lockEntry.integrity, path: key.slice(13), version: lockEntry.version }; + }).sort((left, right) => left.path.localeCompare(right.path)); + if (!["@openai/codex", "typescript"].every((name) => packages.some((entry) => entry.path === name))) throw new Error("Dependency lock lacks pinned Codex or TypeScript"); + dependency_lock = { package_lock_sha256: `sha256:${sha256(lock)}`, packages, required, target: "linux/amd64" }; + } + if (profile === "go-dependencies") { + const required = ["go.mod", "go.sum", "gomodcache"], included = new Set(entries.map((entry) => entry.path)); + if (required.some((entry) => !included.has(entry))) throw new Error("Go dependency bundle lacks its module graph or cache"); + dependency_lock = { go_mod_sha256: `sha256:${sha256(readFileSync(path.join(canonicalRoot, "go.mod")))}`, go_sum_sha256: `sha256:${sha256(readFileSync(path.join(canonicalRoot, "go.sum")))}`, required, target: "linux/amd64" }; + } + return { entries, ...(dependency_lock ? { dependency_lock } : {}), exclude_policy: { credential_content: credentialContent.source, credential_directories: [...credentialDirectory].sort(), credential_files: credentialFile.source, names: [...names].sort(), secret_names: secretName.source, editor_backups: true, profile }, root: ".", version: "spawnfile.source-input-manifest.v1" }; +}; + +export const canonicalManifestBytes = (manifest) => Buffer.from(`${JSON.stringify(manifest)}\n`); +export const sourceManifestDigest = (manifest) => `sha256:${sha256(canonicalManifestBytes(manifest))}`; + +export const assertManifestStable = (root, expected) => { + const actual = canonicalManifestBytes(collectSourceManifest(root)); + if (!actual.equals(canonicalManifestBytes(expected))) throw new Error("Source inputs drifted while the provenance bundle was created"); +}; + +const octal = (value, width) => `${value.toString(8).padStart(width - 1, "0")}\0`; +const tarHeader = (name, mode, size, type, link = "") => { + const header = Buffer.alloc(512); + const put = (value, offset, length) => header.write(value, offset, Math.min(length, Buffer.byteLength(value)), "utf8"); + let basename = name, prefix = ""; + if (Buffer.byteLength(name) > 100) { + const candidates = [...name.matchAll(/\//gu)].map((match) => match.index).reverse(); + const split = candidates.find((index) => Buffer.byteLength(name.slice(0, index)) <= 155 && Buffer.byteLength(name.slice(index + 1)) <= 100); + if (split === undefined) throw new Error(`Source bundle path exceeds the deterministic ustar bound: ${name}`); + prefix = name.slice(0, split); basename = name.slice(split + 1); + } + if (Buffer.byteLength(link) > 100) throw new Error("Source bundle link exceeds the deterministic ustar bound"); + put(basename, 0, 100); put(octal(mode, 8), 100, 8); put(octal(0, 8), 108, 8); put(octal(0, 8), 116, 8); + put(octal(size, 12), 124, 12); put(octal(0, 12), 136, 12); header.fill(32, 148, 156); header[156] = type.charCodeAt(0); + put(link, 157, 100); put("ustar\0", 257, 6); put("00", 263, 2); put("root", 265, 32); put("root", 297, 32); put(prefix, 345, 155); + put(`${header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, "0")}\0 `, 148, 8); + return header; +}; + +const paxPathRecord = (name) => { + const body = `path=${name}\n`; let length = Buffer.byteLength(body) + 3; + for (;;) { const next = Buffer.byteLength(`${length} ${body}`); if (next === length) return Buffer.from(`${length} ${body}`); length = next; } +}; + +export const createSourceBundle = (root, outputPath, profile = "source", hooks = {}) => { + const relativeOutput = path.relative(realpathSync(root), path.resolve(outputPath)); + if (relativeOutput && relativeOutput !== ".." && !relativeOutput.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeOutput)) { + throw new Error("Source bundle output must be outside its input root"); + } + const manifest = collectSourceManifest(root, profile), chunks = []; + const headerName = (name) => { + try { tarHeader(name, 0o644, 0, "0"); return name; } catch (error) { + if (!/path exceeds/u.test(error.message)) throw error; + const pax = paxPathRecord(name), identity = sha256(Buffer.from(name)).slice(0, 32); + chunks.push(tarHeader(`PaxHeaders/${identity}`, 0o644, pax.length, "x"), pax, Buffer.alloc((512 - pax.length % 512) % 512)); + return `entry-${identity}`; + } + }; + const append = (name, bytes, mode = 0o644) => { + chunks.push(tarHeader(headerName(name), mode, bytes.length, "0"), bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + }; + append(".spawnfile-source-manifest.json", canonicalManifestBytes(manifest)); + for (const entry of manifest.entries) { + if (entry.type === "symlink") chunks.push(tarHeader(headerName(entry.path), entry.mode, 0, "2", entry.link)); + else if (entry.type === "directory") chunks.push(tarHeader(headerName(entry.path), entry.mode, 0, "5")); + else append(entry.path, readFileSync(path.join(root, entry.path)), entry.mode); + } + hooks.afterRead?.(); + const actual = canonicalManifestBytes(collectSourceManifest(root, profile)); + if (!actual.equals(canonicalManifestBytes(manifest))) throw new Error("Source inputs drifted while the provenance bundle was created"); + const bytes = Buffer.concat([...chunks, Buffer.alloc(1024)]); + writeFileSync(outputPath, bytes, { mode: 0o600 }); + return { archive_sha256: `sha256:${sha256(bytes)}`, manifest, manifest_sha256: sourceManifestDigest(manifest) }; +}; + +const tarText = (field) => { const end = field.indexOf(0); return field.subarray(0, end < 0 ? field.length : end).toString("utf8"); }; +const tarNumber = (field) => { const text = field.toString("ascii").replace(/\0.*$/u, "").trim(); if (!/^[0-7]+$/u.test(text)) throw new Error("Source bundle has an invalid numeric field"); return Number.parseInt(text, 8); }; + +export const validateSourceBundle = (bytes) => { + if (bytes.length < 1024 || bytes.length % 512) throw new Error("Source bundle is truncated"); + const files = new Map(); let offset = 0, pendingPath, terminated = false; + while (offset + 512 <= bytes.length) { + const header = bytes.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { if (!bytes.subarray(offset).every((byte) => byte === 0)) throw new Error("Source bundle has trailing data"); terminated = true; break; } + if (tarText(header.subarray(257, 263)) !== "ustar") throw new Error("Source bundle is not strict ustar"); + const expected = tarNumber(header.subarray(148, 156)); let sum = 0; + for (let index = 0; index < 512; index += 1) sum += index >= 148 && index < 156 ? 32 : header[index]; + if (sum !== expected) throw new Error("Source bundle checksum mismatch"); + const basename = tarText(header.subarray(0, 100)), prefix = tarText(header.subarray(345, 500)); + const headerPath = prefix ? `${prefix}/${basename}` : basename, size = tarNumber(header.subarray(124, 136)), type = String.fromCharCode(header[156] || 48); + const content = bytes.subarray(offset + 512, offset + 512 + size); if (content.length !== size) throw new Error("Source bundle is truncated"); + if (type === "x") { + if (pendingPath) throw new Error("Source bundle has stacked path extensions"); + const record = content.toString("utf8"), match = record.match(/^([1-9][0-9]*) path=([^\n]+)\n$/u); + if (!match || Number(match[1]) !== content.length || !safeRelative(match[2])) throw new Error("Source bundle has an unsafe path extension"); + pendingPath = match[2]; offset += 512 + Math.ceil(size / 512) * 512; continue; + } + const rawName = pendingPath ?? headerPath; pendingPath = undefined; const name = type === "5" && rawName.endsWith("/") ? rawName.slice(0, -1) : rawName; + if (!safeRelative(name) || files.has(name) || !["0", "2", "5"].includes(type) || (type !== "0" && size !== 0)) throw new Error("Source bundle contains an unsafe entry"); + files.set(name, { content, link: tarText(header.subarray(157, 257)), type }); + offset += 512 + Math.ceil(size / 512) * 512; + } + if (!terminated || pendingPath) throw new Error("Source bundle lacks exact termination"); + const manifestFile = files.get(".spawnfile-source-manifest.json"); if (!manifestFile || manifestFile.type !== "0") throw new Error("Source bundle lacks its input manifest"); + let manifest; try { manifest = JSON.parse(manifestFile.content.toString("utf8")); } catch { throw new Error("Source bundle manifest is invalid JSON"); } + const profile = manifest.exclude_policy?.profile, names = profile === "dependencies" || profile === "go-dependencies" ? dependencyExcludedNames : profile === "build-source" ? buildSourceExcludedNames : sourceExcludedNames; + const dependencyLockValid = profile !== "dependencies" || (manifest.dependency_lock?.target === "linux/amd64" && /^sha256:[a-f0-9]{64}$/u.test(manifest.dependency_lock?.package_lock_sha256) && + Array.isArray(manifest.dependency_lock?.packages) && manifest.dependency_lock.packages.every((entry) => /^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(entry.integrity) && typeof entry.path === "string" && typeof entry.version === "string") && + JSON.stringify(manifest.dependency_lock?.required) === JSON.stringify(["npm-cache", "package-lock.json", "package.json"])); + const goDependencyLockValid = profile !== "go-dependencies" || (manifest.dependency_lock?.target === "linux/amd64" && /^sha256:[a-f0-9]{64}$/u.test(manifest.dependency_lock?.go_mod_sha256) && /^sha256:[a-f0-9]{64}$/u.test(manifest.dependency_lock?.go_sum_sha256) && JSON.stringify(manifest.dependency_lock?.required) === JSON.stringify(["go.mod", "go.sum", "gomodcache"])); + if (!["source", "build-source", "dependencies", "go-dependencies"].includes(profile) || manifest.version !== "spawnfile.source-input-manifest.v1" || !Array.isArray(manifest.entries) || manifest.root !== "." || + !dependencyLockValid || + !goDependencyLockValid || + JSON.stringify(manifest.exclude_policy) !== JSON.stringify({ credential_content: credentialContent.source, credential_directories: [...credentialDirectory].sort(), credential_files: credentialFile.source, names: [...names].sort(), secret_names: secretName.source, editor_backups: true, profile }) || + !canonicalManifestBytes(manifest).equals(manifestFile.content)) throw new Error("Source bundle manifest is not canonical"); + if (files.size !== manifest.entries.length + 1) throw new Error("Source bundle and manifest entry sets differ"); + for (const entry of manifest.entries) { + const file = files.get(entry.path), expectedType = entry.type === "file" ? "0" : entry.type === "symlink" ? "2" : "5"; + if (!file || file.type !== expectedType) throw new Error("Source bundle and manifest entry sets differ"); + if (entry.type === "file" && (`sha256:${sha256(file.content)}` !== entry.sha256 || file.content.length !== entry.size)) throw new Error("Source bundle file digest mismatch"); + if (entry.type === "symlink") { + const target = path.posix.normalize(path.posix.join(path.posix.dirname(entry.path), file.link)); + if (file.link !== entry.link || !safeRelative(target) || !files.has(target)) throw new Error("Source bundle symlink mismatch"); + } + } + assertSymlinkGraph(manifest.entries); + return { archive_sha256: `sha256:${sha256(bytes)}`, manifest, manifest_sha256: sourceManifestDigest(manifest) }; +}; diff --git a/scripts/verify-package-closure.mjs b/scripts/verify-package-closure.mjs index d4d9e340..9fb6682c 100644 --- a/scripts/verify-package-closure.mjs +++ b/scripts/verify-package-closure.mjs @@ -7,6 +7,8 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL, fileURLToPath } from "node:url"; +import { verifyNativeHelperArtifacts } from "./native-helper-artifacts.mjs"; + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const packageRoot = path.resolve(scriptDirectory, ".."); const packageManifestPath = path.join(packageRoot, "package.json"); @@ -139,6 +141,7 @@ const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { installedRoot, "dist", "evidenceExportHelper", "recipe.js", )).href); const helper = await helperRecipe.loadLocalEvidenceHelperRecipe(); + await verifyNativeHelperArtifacts(path.join(installedRoot, "dist", "deployment", "native")); const helperSource = await readFile(helperProgram, "utf8"); if (!helperSource.startsWith("#!/usr/local/bin/node") || !(helper.context instanceof Uint8Array) @@ -150,7 +153,8 @@ const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { "dist/compiler/moltnetBinaries.js", )).href); const expectedMoltnetExports = [ - "MOLTNET_BINARY_NAMES", "MOLTNET_BIN_DIRECTORY", "MOLTNET_RELEASE_DIR_ENV", + "MOLTNET_ALLOW_LOCAL_E2E_ENV", "MOLTNET_BINARY_NAMES", "MOLTNET_BIN_DIRECTORY", + "MOLTNET_LOCAL_RELEASE_DIR_ENV", "MOLTNET_RELEASE_DIR_ENV", "MOLTNET_RELEASE_IDENTITY_VERSION", "MOLTNET_RELEASE_STAMP_VERSION", "resolveMoltnetCliCommand", "stageMoltnetBinaries", ]; @@ -235,6 +239,12 @@ const main = async () => { || !entries.has("dist/evidenceExportHelper/recipe.js")) { fail("packed tarball omits the evidence helper runtime assets"); } + for (const architecture of ["x64", "arm64"]) { + if (!entries.has(`dist/deployment/native/rename-noreplace-${architecture}`) + || !entries.has(`dist/deployment/native/rename-noreplace-${architecture}.provenance.json`)) { + fail(`packed tarball omits the Linux ${architecture} rename-noreplace helper or provenance`); + } + } if ([...entries].some((entry) => /\.test-helper\.(?:js|d\.ts)$/u.test(entry))) { fail("packed tarball leaked a test helper"); } From 62b7f65e75b4288c0678942a780eed975a6803b9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:41:59 +0200 Subject: [PATCH 07/34] feat(compiler): provision isolated runtime organizations --- src/compiler/AGENTS.md | 1 + src/compiler/compileProject.test.ts | 42 +- src/compiler/compileProject.ts | 6 + src/compiler/containerArtifacts.test.ts | 16 +- src/compiler/containerArtifacts.ts | 69 +-- src/compiler/containerArtifactsPlans.test.ts | 123 ++++- src/compiler/containerArtifactsPlans.ts | 13 +- src/compiler/containerArtifactsRender.test.ts | 73 ++- src/compiler/containerArtifactsRender.ts | 33 +- .../containerArtifactsResources.test.ts | 33 ++ .../containerDaimonBrokerRender.test.ts | 98 ++++ src/compiler/containerDaimonBrokerRender.ts | 109 ++++ .../containerDaimonOwnershipGuardRender.ts | 183 +++++++ ...tainerDaimonUidEntrypointLifecycle.test.ts | 396 ++++++++++++++ ...containerDaimonUidEntrypointRender.test.ts | 370 ++++++------- .../containerDaimonUidEntrypointRender.ts | 288 ++++++---- .../containerEntrypointRender.test.ts | 68 ++- src/compiler/containerEntrypointRender.ts | 58 +- src/compiler/containerReadinessPaths.ts | 1 + .../containerRuntimeLinkMaterializer.ts | 13 + src/compiler/containerStateOwnershipRender.ts | 19 +- src/compiler/containerTargetResources.ts | 107 +++- src/compiler/containerVolumeBootstrap.ts | 3 + .../containerWorkspaceResourceRender.ts | 49 +- src/compiler/moltnetArtifactPaths.test.ts | 4 +- src/compiler/moltnetArtifactPaths.ts | 14 +- src/compiler/moltnetArtifactTypes.ts | 2 + src/compiler/moltnetArtifacts.ts | 25 +- .../organizationReadyEvidence.test.ts | 8 + src/compiler/organizationReadyEvidence.ts | 27 +- src/compiler/runProject.runner.test.ts | 148 +++++ src/compiler/runProject.test.ts | 504 +----------------- src/compiler/runProject.ts | 18 +- src/compiler/runProjectDeployment.test.ts | 301 +++++++++++ src/compiler/runProjectDocker.ts | 36 +- src/compiler/runProjectDockerReservation.ts | 133 +++++ src/compiler/runProjectExecution.test.ts | 264 +++++++++ .../runProjectInvocationAdditional.test.ts | 317 +++++++++++ .../runProjectPersistentMounts.test.ts | 29 + src/compiler/syncProjectAuth.test.ts | 9 +- src/compiler/types.ts | 1 + src/compiler/upProject.test.ts | 5 +- src/compiler/upProject.ts | 19 + .../upProjectOrganizationHandoff.test.ts | 9 +- src/compiler/view/types.ts | 2 +- src/shared/index.ts | 1 + src/shared/volumeNames.test.ts | 10 + src/shared/volumeNames.ts | 15 + 48 files changed, 3161 insertions(+), 911 deletions(-) create mode 100644 src/compiler/containerDaimonBrokerRender.test.ts create mode 100644 src/compiler/containerDaimonBrokerRender.ts create mode 100644 src/compiler/containerDaimonOwnershipGuardRender.ts create mode 100644 src/compiler/containerDaimonUidEntrypointLifecycle.test.ts create mode 100644 src/compiler/containerReadinessPaths.ts create mode 100644 src/compiler/containerRuntimeLinkMaterializer.ts create mode 100644 src/compiler/containerVolumeBootstrap.ts create mode 100644 src/compiler/runProjectDeployment.test.ts create mode 100644 src/compiler/runProjectDockerReservation.ts create mode 100644 src/compiler/runProjectExecution.test.ts create mode 100644 src/compiler/runProjectInvocationAdditional.test.ts create mode 100644 src/compiler/runProjectPersistentMounts.test.ts create mode 100644 src/shared/volumeNames.test.ts create mode 100644 src/shared/volumeNames.ts diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index e0ced10b..7a29958e 100644 --- a/src/compiler/AGENTS.md +++ b/src/compiler/AGENTS.md @@ -29,6 +29,7 @@ src/compiler/ ├── containerConfigEnvRender.ts # Generic JSON config-env command and entrypoint materialization rendering ├── containerEntrypointRender.ts # Generated container entrypoint orchestration ├── containerEntrypointShell.ts # Shell quoting, recipe env, and CLI credential materialization helpers +├── containerDaimonBrokerRender.ts # Fixed Daimon broker identities, registrations, worker config, and root-launch provisioning ├── containerArtifactsPlans.ts # Environment inventory and runtime target-plan orchestration ├── containerTargetPlanResolution.ts # Per-target paths, packages, auth, secrets, and exposure resolution ├── teamRoster.ts # Context-scoped team roster generation and diagnostics diff --git a/src/compiler/compileProject.test.ts b/src/compiler/compileProject.test.ts index 052b3b69..04f12045 100644 --- a/src/compiler/compileProject.test.ts +++ b/src/compiler/compileProject.test.ts @@ -16,6 +16,7 @@ import { import { compileProject } from "./compileProject.js"; import { TRUSTED_TEST_MOLTNET_RELEASE_AUTHORITY } from "../../fixtures/support/trustedMoltnetRelease.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "../runtime/daimon/contractManifest.js"; vi.mock("./moltnetBinaries.js", async (importOriginal) => { const actual = await importOriginal(); @@ -81,7 +82,27 @@ const createFakeMoltnetCli = async (): Promise => { return cliPath; }; +const useCompatibleDaimonRuntime = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-compile-daimon-")); + temporaryDirectories.push(directory); + const identityPath = path.join(directory, "identity.json"); + const digest = `sha256:${"a".repeat(64)}`; + await writeUtf8File(identityPath, `${JSON.stringify({ + capability_receipt_sha256: digest, + development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, + image_architecture: "amd64", + image_config_digest: digest, + image_manifest_digest: digest, + image_reference: `127.0.0.1:54321/noopolis/spawnfile-runtime-daimon@${digest}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" + })}\n`); + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = identityPath; +}; + afterEach(async () => { + delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); }); @@ -209,6 +230,7 @@ describe("compileProject", () => { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-inline-compile-")); const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-inline-output-")); temporaryDirectories.push(directory, outputDirectory); + await useCompatibleDaimonRuntime(); await ensureDirectory(path.join(directory, "characters")); await writeUtf8File(path.join(directory, "characters", "red.md"), "# Red sentinel\n"); @@ -480,7 +502,11 @@ describe("compileProject", () => { link_path: "/var/lib/spawnfile/instances/openclaw/agent-worker/home/.openclaw/workspace/cache", mode: "readonly", mount: "./cache", - sharing: "per_agent" + mount_path: "/var/lib/spawnfile/instances/openclaw/agent-worker/home/.openclaw/workspace/cache", + replacement_sentinel: { path: expect.stringContaining("/.spawnfile-resource-identity"), result: "verified_on_startup" }, + resolved_identity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + sharing: "per_agent", + volume_name: expect.stringMatching(/^spawnfile-workspace-resource-[a-f0-9]{24}$/u) }, { backing_path: expect.stringContaining("/var/lib/spawnfile/resources/instances/agent-worker-"), @@ -489,7 +515,11 @@ describe("compileProject", () => { link_path: "/var/lib/spawnfile/instances/openclaw/agent-worker/home/.openclaw/workspace/repos/project", mode: "mutable", mount: "./repos/project", - sharing: "per_agent" + mount_path: "/var/lib/spawnfile/instances/openclaw/agent-worker/home/.openclaw/workspace/repos/project", + replacement_sentinel: undefined, + resolved_identity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + sharing: "per_agent", + volume_name: null } ]); expect(entrypoint).toContain( @@ -1014,7 +1044,8 @@ describe("compileProject", () => { } ] }); - expect(container?.persistent_mounts?.map((mount) => mount.id).sort()).toEqual([ + const mountIds = container?.persistent_mounts?.map((mount) => mount.id).sort() ?? []; + expect(mountIds).toEqual(expect.arrayContaining([ "agent-mapper-daimon-telemetry", "agent-mapper-moltnet-tokens", "agent-reviewer-daimon-telemetry", @@ -1022,7 +1053,8 @@ describe("compileProject", () => { "memory-var-lib-spawnfile-memory-daimon-org", "moltnet-daimon_lab-causal", "moltnet-daimon_lab-store" - ]); + ])); + expect(mountIds.filter((id) => id.startsWith("workspace-resource-"))).toHaveLength(3); const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); expect(dockerfile).toContain("FROM node:24-bookworm-slim"); @@ -1030,7 +1062,7 @@ describe("compileProject", () => { expect(dockerfile).toContain("COPY container/rootfs/ /"); const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); - expect(entrypoint).toContain("/usr/local/bin/moltnet &"); + expect(entrypoint).toContain("/usr/local/bin/moltnet start --config"); expect(entrypoint).toContain("/usr/local/bin/moltnet node"); expect(entrypoint).toContain( "'node' '/opt/spawnfile/runtime-installs/pi/app.mjs' '/var/lib/spawnfile/instances/pi/pi-app/pi/pi-app.json'" diff --git a/src/compiler/compileProject.ts b/src/compiler/compileProject.ts index aa205d52..637afcfa 100644 --- a/src/compiler/compileProject.ts +++ b/src/compiler/compileProject.ts @@ -25,6 +25,7 @@ import { stageRuntimePackageOverrides, type RuntimePackageOverrideRequest } from "./containerPackageOverrides.js"; +import { stageWorkspaceBundles } from "./workspaceBundleArtifacts.js"; import { augmentNodeReports } from "./compileProjectReports.js"; import { enforcePolicy, @@ -61,6 +62,8 @@ export type { export interface CompileProjectOptions { clean?: boolean; containerArchitecture?: MoltnetTargetArchitecture; + /** Stable deployment identity for exclusive credential-realm reattachment. */ + deploymentLineage?: string; outputDirectory?: string; /** Compile-time-only local overrides for runtime install npm packages * (see src/compiler/containerPackageOverrides.ts). Only opt-in @@ -321,10 +324,13 @@ export const compileProject = async ( outputDirectory, options.runtimePackageOverrides ); + const hasWorkspaceBundles = await stageWorkspaceBundles(outputDirectory, plan); const generatedAt = new Date().toISOString(); const containerArtifacts = await createContainerArtifacts(plan, compiledNodes, { + deploymentLineage: options.deploymentLineage, generatedAt, hasStagedMoltnetBinaries: Boolean(moltnetRelease), + hasWorkspaceBundles, moltnet: moltnetArtifacts, moltnetRelease, runtimePackageOverrides: resolvedRuntimePackageOverrides, diff --git a/src/compiler/containerArtifacts.test.ts b/src/compiler/containerArtifacts.test.ts index e6d7c1d0..11bbdcce 100644 --- a/src/compiler/containerArtifacts.test.ts +++ b/src/compiler/containerArtifacts.test.ts @@ -419,7 +419,7 @@ describe("createContainerArtifacts", () => { "mkdir -p '/var/lib/spawnfile' '/var/lib/spawnfile/moltnet/networks/local-lab'" ); expect(dockerfile).toContain( - "touch '/var/lib/spawnfile/moltnet/networks/local-lab/.spawnfile-volume-init'" + "spawnfile.volume-bootstrap.v1" ); }); @@ -512,7 +512,7 @@ describe("createContainerArtifacts", () => { "mkdir -p '/var/lib/spawnfile' '/var/lib/spawnfile/memory/assistant/shared-memory'" ); expect(dockerfile).toContain( - "touch '/var/lib/spawnfile/memory/assistant/shared-memory/.spawnfile-volume-init'" + "spawnfile.volume-bootstrap.v1" ); }); @@ -648,7 +648,11 @@ describe("createContainerArtifacts", () => { link_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/workspace/cache", mode: "readonly", mount: "./cache", - sharing: "per_agent" + mount_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/workspace/cache", + replacement_sentinel: { path: expect.stringContaining("/.spawnfile-resource-identity"), result: "verified_on_startup" }, + resolved_identity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + sharing: "per_agent", + volume_name: expect.stringMatching(/^spawnfile-workspace-resource-[a-f0-9]{24}$/u) }, { backing_path: expect.stringContaining("/var/lib/spawnfile/resources/instances/agent-assistant-"), @@ -657,7 +661,11 @@ describe("createContainerArtifacts", () => { link_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/workspace/repos/project", mode: "mutable", mount: "./repos/project", - sharing: "per_agent" + mount_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/workspace/repos/project", + replacement_sentinel: undefined, + resolved_identity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), + sharing: "per_agent", + volume_name: null } ]); }); diff --git a/src/compiler/containerArtifacts.ts b/src/compiler/containerArtifacts.ts index b22bfa92..420af93f 100644 --- a/src/compiler/containerArtifacts.ts +++ b/src/compiler/containerArtifacts.ts @@ -1,45 +1,31 @@ import { createHash } from "node:crypto"; -import { - buildDistributionReport, - createDistributionImageLabels, - DISTRIBUTION_REPORT_OUTPUT_FILE, - normalizeProjectLabelSlug, - WORLD_BINDINGS_IMAGE_PATH -} from "../distribution/index.js"; +import { buildDistributionReport, createDistributionImageLabels, DISTRIBUTION_REPORT_OUTPUT_FILE, normalizeProjectLabelSlug, WORLD_BINDINGS_IMAGE_PATH } from "../distribution/index.js"; import type { DistributionReport } from "../distribution/index.js"; -import type { EmittedFile, RuntimeContainerPackageOverrides } from "../runtime/index.js"; +import type { ContainerPersistentMountReport } from "../report/index.js"; +import { DAIMON_LOCAL_RUNTIME_IDENTITY_ENV, loadLocalDaimonRuntimeIdentity, type EmittedFile, type RuntimeContainerPackageOverrides } from "../runtime/index.js"; import { SpawnfileError } from "../shared/index.js"; import { createMoltnetSummary, createOrganizationSummary } from "./containerArtifactSummaries.js"; import { createEnvVariableMap, createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; import { createDockerIgnoreContent } from "./dockerBuildContext.js"; import { createDaimonTelemetryArtifacts } from "./daimonTelemetryArtifacts.js"; -import { - createRootfsFiles, - renderDockerfile, - renderEntrypoint, - renderEnvExample -} from "./containerArtifactsRender.js"; +import { createRootfsFiles, renderDockerfile, renderEntrypoint, renderEnvExample } from "./containerArtifactsRender.js"; import { createMemoryArtifactBundle } from "./memoryArtifacts.js"; import type { MoltnetArtifacts } from "./moltnetArtifacts.js"; import type { MoltnetReleaseIdentity } from "./moltnetBinaries.js"; -import type { - CompiledNodeArtifact, - GeneratedContainerArtifacts -} from "./containerArtifactsTypes.js"; +import type { CompiledNodeArtifact, GeneratedContainerArtifacts } from "./containerArtifactsTypes.js"; +import { resolveWorkspaceResourceVolumes, type ResolvedTargetResourcePlan } from "./containerTargetResources.js"; import type { CompilePlan } from "./types.js"; -import { - SIMFILE_WORLD_BINDINGS_VERSION, - WORLD_BINDINGS_OUTPUT_FILE, - type ResolvedWorldBindings -} from "./worldBindings.js"; +import { SIMFILE_WORLD_BINDINGS_VERSION, WORLD_BINDINGS_OUTPUT_FILE, type ResolvedWorldBindings } from "./worldBindings.js"; export type { CompiledNodeArtifact, GeneratedContainerArtifacts } from "./containerArtifactsTypes.js"; export interface ContainerArtifactOptions { + deploymentLineage?: string; generatedAt?: string; hasStagedMoltnetBinaries?: boolean; + hasWorkspaceBundles?: boolean; moltnet?: MoltnetArtifacts | null; moltnetRelease?: MoltnetReleaseIdentity; worldBindings?: ResolvedWorldBindings; @@ -55,7 +41,9 @@ export const createContainerArtifacts = async ( compiledNodes: CompiledNodeArtifact[], options: ContainerArtifactOptions = {} ): Promise => { - const runtimePlans = await createRuntimeTargetPlans(plan, compiledNodes, options.worldBindings); + const localDaimonIdentityPath = process.env[DAIMON_LOCAL_RUNTIME_IDENTITY_ENV]?.trim(); + const localDaimonIdentity = localDaimonIdentityPath ? await loadLocalDaimonRuntimeIdentity(localDaimonIdentityPath) : undefined; + const runtimePlans = await createRuntimeTargetPlans(plan, compiledNodes, options.worldBindings, options.deploymentLineage); const daimonTelemetryArtifacts = createDaimonTelemetryArtifacts(plan, runtimePlans, compiledNodes); const envVariableMap = createEnvVariableMap(compiledNodes, runtimePlans, options.moltnet); const projectedWorldTokenEnvNames = [...new Set( @@ -105,7 +93,9 @@ export const createContainerArtifacts = async ( .map((variable) => variable.name) .sort(); const memoryArtifacts = createMemoryArtifactBundle(plan); - const persistentMountsById = new Map(); + const { resources: resolvedWorkspaceResources, mounts: workspaceResourceMounts } = + resolveWorkspaceResourceVolumes(runtimePlans); + const persistentMountsById = new Map(); for (const mount of memoryArtifacts.mounts) { const existing = persistentMountsById.get(mount.id); if (existing) { @@ -117,14 +107,16 @@ export const createContainerArtifacts = async ( continue; } persistentMountsById.set(mount.id, { + ...(mount.lifecycle ? { lifecycle: mount.lifecycle } : {}), mount_path: mount.mount_path, reason: mount.reason, volume_name: mount.volume_name }); } - const persistentMounts = [ + const persistentMountCandidates: ContainerPersistentMountReport[] = [ ...memoryArtifacts.mounts, + ...workspaceResourceMounts, ...daimonTelemetryArtifacts.mounts, ...runtimePlans.flatMap((runtimePlan) => runtimePlan.persistentMounts ?? []), ...((options.moltnet?.persistentMounts ?? []).map((mount) => ({ @@ -133,7 +125,8 @@ export const createContainerArtifacts = async ( reason: mount.reason, volume_name: mount.volumeName }))) - ] + ]; + const persistentMounts = persistentMountCandidates .sort((left, right) => left.id.localeCompare(right.id)) .filter((mount) => { const existing = persistentMountsById.get(mount.id); @@ -141,7 +134,8 @@ export const createContainerArtifacts = async ( if ( existing.mount_path === mount.mount_path && existing.volume_name === mount.volume_name && - existing.reason === mount.reason + existing.reason === mount.reason && + existing.lifecycle === mount.lifecycle ) { return true; } @@ -151,6 +145,7 @@ export const createContainerArtifacts = async ( ); } persistentMountsById.set(mount.id, { + ...(mount.lifecycle ? { lifecycle: mount.lifecycle } : {}), mount_path: mount.mount_path, reason: mount.reason, volume_name: mount.volume_name @@ -208,7 +203,7 @@ export const createContainerArtifacts = async ( const workspaceResources = [ ...new Map( runtimePlans.flatMap((plan) => - (plan.resources ?? []).map((resource) => [ + ((plan.resources ?? []) as ResolvedTargetResourcePlan[]).map((resource) => [ `${resource.kind}:${resource.id}:${resource.linkPath}`, { backing_path: resource.backingPath, @@ -217,7 +212,14 @@ export const createContainerArtifacts = async ( link_path: resource.linkPath, mode: resource.mode, mount: resource.mount, - sharing: resource.sharing + mount_path: resource.linkPath, + replacement_sentinel: resource.replacementSentinel ? { + path: resource.replacementSentinel, + result: "verified_on_startup" as const + } : undefined, + resolved_identity: resource.resolvedIdentity, + sharing: resource.sharing, + volume_name: resource.volumeName ?? null } ]) ) @@ -266,6 +268,7 @@ export const createContainerArtifacts = async ( durability: "persistent" as const, id: mount.id, kind: "volume" as const, + ...(mount.lifecycle ? { lifecycle: mount.lifecycle } : {}), target: mount.mount_path })), portMappings, @@ -317,6 +320,7 @@ export const createContainerArtifacts = async ( }, hasMoltnet: Boolean(options.moltnet), hasStagedMoltnetBinaries: options.hasStagedMoltnetBinaries, + hasWorkspaceBundles: options.hasWorkspaceBundles, ...(options.moltnet ? { moltnet: { @@ -378,6 +382,11 @@ export const createContainerArtifacts = async ( entrypoint: "entrypoint.sh", env_example: ".env.example", internal_ports: internalPorts, + ...(localDaimonIdentity ? { local_daimon_runtime: { + capability_receipt_sha256: localDaimonIdentity.capabilityReceipt, + image_reference: localDaimonIdentity.imageReference, + registry_authority: localDaimonIdentity.registryAuthority + } } : {}), model_secrets_required: modelSecretsRequired, ...(moltnetSummary ? { moltnet: moltnetSummary } : {}), port_mappings: portMappings, diff --git a/src/compiler/containerArtifactsPlans.test.ts b/src/compiler/containerArtifactsPlans.test.ts index b4129532..41a9844a 100644 --- a/src/compiler/containerArtifactsPlans.test.ts +++ b/src/compiler/containerArtifactsPlans.test.ts @@ -1,12 +1,44 @@ -import { describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; import { openClawAdapter } from "../runtime/openclaw/adapter.js"; import { daimonAdapter } from "../runtime/daimon/adapter.js"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "../runtime/daimon/contractManifest.js"; import { createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { CompilePlan, ResolvedAgentNode, ResolvedTeamNode } from "./types.js"; +const temporaryDirectories: string[] = []; +const useCompatibleDaimonRuntime = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-plan-daimon-")); + temporaryDirectories.push(directory); + const identity = path.join(directory, "identity.json"); + const digest = `sha256:${"a".repeat(64)}`; + await writeFile(identity, `${JSON.stringify({ + capability_receipt_sha256: digest, + development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, + image_architecture: "amd64", + image_config_digest: digest, + image_manifest_digest: digest, + image_reference: `127.0.0.1:54321/noopolis/spawnfile-runtime-daimon@${digest}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, + registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" + })}\n`, { mode: 0o600 }); + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = identity; +}; + +afterEach(async () => { + delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; + await Promise.all(temporaryDirectories.splice(0).map((directory) => + rm(directory, { force: true, recursive: true }) + )); +}); + const createAgent = (): ResolvedAgentNode => ({ description: "", docs: [], @@ -78,6 +110,7 @@ describe("runtime target plan source identity", () => { }); it("keeps an empty Daimon workspace in the one organization target", async () => { + await useCompatibleDaimonRuntime(); const node: ResolvedAgentNode = { ...createAgent(), runtime: { name: "daimon", options: { engine: "agy" } } @@ -86,7 +119,7 @@ describe("runtime target plan source identity", () => { expect(compiled.files).toEqual([]); const priorRunId = process.env.NOOPOLIS_RUN_ID; - process.env.NOOPOLIS_RUN_ID = "run-that-must-not-scope-the-host-realm"; + process.env.NOOPOLIS_RUN_ID = "candidate-blue"; let result: Awaited>; try { result = await createRuntimeTargetPlans({ @@ -118,15 +151,97 @@ describe("runtime target plan source identity", () => { id: "daimon-agy-runtime-home-assistant", mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/assistant", reason: "Daimon AGY subscription runtime home for agent:assistant", - volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-runtime-home-assistant") + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-runtime-home-assistant", undefined, "candidate-blue") }, { id: "daimon-agy-subscription-realm", mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", reason: "Daimon host AGY subscription realm", - volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-subscription-realm") + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-subscription-realm", undefined, "candidate-blue") + }, + { + id: "daimon-organization-acceptance-store", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance", + reason: "Daimon organization durable wake acceptance store", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-organization-acceptance-store", undefined, "candidate-blue") + }, + { + id: "daimon-tool-state-assistant", + 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", undefined, "candidate-blue") } ] })); }); + + it("assigns stable isolated volumes to portable Daimon engine homes", async () => { + await useCompatibleDaimonRuntime(); + const agents = ([ + ["writer", "codex"], + ["scout", "grok"] + ] as const).map(([slug, engine]) => ({ + node: { + ...createAgent(), + name: slug, + runtime: { name: "daimon", options: { engine } } + } as ResolvedAgentNode, + slug + })); + const compiled = await Promise.all(agents.map(async ({ node, slug }) => ({ + emittedFiles: (await daimonAdapter.compileAgent(node)).files, + id: `agent:${slug}`, + kind: "agent" as const, + runtimeName: "daimon", + slug, + value: node + }))); + const result = await createRuntimeTargetPlans({ + edges: [], + nodes: [], + root: "/tmp/Spawnfile", + runtimes: { daimon: { nodeIds: [] } } + }, compiled); + + expect(result[0]?.persistentMounts).toEqual([ + { + id: "daimon-engine-home-codex-writer", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/writer/.codex", + reason: "Daimon codex subscription credential home for agent:writer", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-engine-home-codex-writer") + }, + { + id: "daimon-engine-home-grok-scout", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/scout/.grok", + reason: "Daimon grok subscription credential home for agent:scout", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-engine-home-grok-scout") + }, + { + id: "daimon-grok-subscription-realm", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/grok-subscription-realm", + reason: "Daimon host Grok subscription credential realm", + volume_name: expect.stringMatching(/^spawnfile-exclusive-daimon-grok-subscription-realm-[a-f0-9]{16}$/u) + }, + { + id: "daimon-organization-acceptance-store", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance", + reason: "Daimon organization durable wake acceptance store", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-organization-acceptance-store") + }, + { + id: "daimon-tool-state-scout", + mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/scout/tool-state", + reason: "Daimon durable cognition tool receipts for agent:scout", + volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-tool-state-scout") + }, + { + id: "daimon-tool-state-writer", + 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") + } + ]); + expect(JSON.stringify(result[0]?.persistentMounts)).not.toMatch(/daimon-inbound|access_token|refresh_token/u); + }); }); diff --git a/src/compiler/containerArtifactsPlans.ts b/src/compiler/containerArtifactsPlans.ts index 062292d2..ab8c7e24 100644 --- a/src/compiler/containerArtifactsPlans.ts +++ b/src/compiler/containerArtifactsPlans.ts @@ -1,5 +1,6 @@ import type { Secret } from "../manifest/index.js"; import { createRuntimeInstallRecipe, getRuntimeAdapter } from "../runtime/index.js"; +import { resolveNoopolisRunId } from "../runtime/index.js"; import type { ContainerTargetInput } from "../runtime/index.js"; import { listAgentSurfaceSecretNames } from "./agentSurfaces.js"; @@ -24,6 +25,7 @@ import { listExecutionModelSecretNames } from "./modelEnv.js"; import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { MoltnetArtifacts } from "./moltnetArtifacts.js"; import type { CompilePlan } from "./types.js"; +import { createExclusiveReattachVolumeName } from "../shared/index.js"; import { findWorldBindingForNode, type ResolvedWorldBindings } from "./worldBindings.js"; export const createEnvVariableMap = ( @@ -118,9 +120,11 @@ export const createEnvVariableMap = ( export const createRuntimeTargetPlans = async ( plan: CompilePlan, compiledNodes: CompiledNodeArtifact[], - worldBindings?: ResolvedWorldBindings + worldBindings?: ResolvedWorldBindings, + deploymentLineage = "compile" ): Promise => { const runtimeNames = Object.keys(plan.runtimes).sort(); + const runId = resolveNoopolisRunId(process.env); const runtimePlans: RuntimeTargetPlan[] = []; for (const runtimeName of runtimeNames) { @@ -166,9 +170,12 @@ export const createRuntimeTargetPlans = async ( ...(target.opaqueMountTargets ? { opaqueMountTargets: [...target.opaqueMountTargets].sort() } : {}), ...(target.persistentMounts ? { persistentMounts: target.persistentMounts.map((mount) => ({ id: mount.id, + ...(mount.lifecycle ? { lifecycle: mount.lifecycle } : {}), mount_path: mount.mountPath.replaceAll("", instancePaths.instanceRoot), reason: mount.reason, - volume_name: createPersistentVolumeName(plan.root, mount.id) + volume_name: mount.lifecycle === "exclusive-reattach" + ? createExclusiveReattachVolumeName(`${plan.root}\0${deploymentLineage}`, mount.id) + : createPersistentVolumeName(plan.root, mount.id, undefined, runId) })).sort((left, right) => left.id.localeCompare(right.id)) } : {}), port: adapter.container.port ? adapter.container.port + (index * portStride) : undefined, publishedPort: @@ -176,7 +183,7 @@ export const createRuntimeTargetPlans = async ( ? adapter.container.port + (index * portStride) : undefined, recipeEnv: recipe.env, - resources: resolveTargetResources(target, targetInputs, instancePaths, adapter.container), + resources: resolveTargetResources(target, targetInputs, instancePaths, adapter.container, plan.root, runId), runtimeName, runtimeRoot: recipe.runtimeRoot, sourceIds: [...(target.sourceIds ?? [])].sort(), diff --git a/src/compiler/containerArtifactsRender.test.ts b/src/compiler/containerArtifactsRender.test.ts index 0e7d5b55..f1cefc4f 100644 --- a/src/compiler/containerArtifactsRender.test.ts +++ b/src/compiler/containerArtifactsRender.test.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import { createStateOwnershipCommand } from "./containerStateOwnershipRender.js"; const execFile = promisify(execFileCallback); const temporaryDirectories: string[] = []; @@ -131,7 +132,7 @@ describe("renderDockerfile", () => { const plan = createRuntimePlan("daimon", { meta: { ...createRuntimePlan("daimon").meta, - systemDeps: ["bash", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux"] + systemDeps: ["bash", "bubblewrap", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux"] } }); const dockerfile = await renderDockerfile([plan], { @@ -139,13 +140,16 @@ describe("renderDockerfile", () => { }); expect(dockerfile).toContain("FROM noopolis/spawnfile-runtime-daimon:test"); expect(dockerfile).toContain( - "apt-get install -y --no-install-recommends dbus-daemon gnome-keyring util-linux" + "apt-get install -y --no-install-recommends bubblewrap dbus-daemon gnome-keyring util-linux" ); expect(dockerfile).not.toContain( "apt-get install -y --no-install-recommends bash ca-certificates curl" ); expect(dockerfile).not.toContain("secret-tool"); expect(dockerfile).not.toContain("dbus-x11"); + expect(dockerfile).toContain("HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=12"); + expect(dockerfile).toContain("/healthz"); + expect(dockerfile).toContain(plan.instancePaths.configPath); }); it("uses the highest node base image when a multi-runtime image includes node runtimes", async () => { @@ -817,7 +821,9 @@ describe("renderEntrypoint", () => { expect(entrypoint).toContain( "MOLTNET_CONFIG='/var/lib/spawnfile/moltnet/servers/local_lab/Moltnet.json'" ); - expect(entrypoint).toContain("/usr/local/bin/moltnet &"); + expect(entrypoint).toContain( + "/usr/local/bin/moltnet start --config '/var/lib/spawnfile/moltnet/servers/local_lab/Moltnet.json' &" + ); expect(entrypoint).toContain("http://127.0.0.1:18789/healthz"); expect(entrypoint).toContain("http://127.0.0.1:8787/healthz"); expect(entrypoint).toContain("/usr/local/bin/moltnet node '/var/lib/spawnfile/moltnet/nodes/research.json' &"); @@ -914,7 +920,9 @@ describe("renderEntrypoint", () => { expect(entrypoint).toContain( "MOLTNET_CONFIG='/var/lib/spawnfile/moltnet/servers/local_lab/Moltnet.json'" ); - expect(entrypoint).toContain("/usr/local/bin/moltnet &"); + expect(entrypoint).toContain( + "/usr/local/bin/moltnet start --config '/var/lib/spawnfile/moltnet/servers/local_lab/Moltnet.json' &" + ); expect(entrypoint).toContain("http://127.0.0.1:18789/healthz"); expect(entrypoint).toContain("picoclaw"); expect(entrypoint).toContain("/usr/local/bin/moltnet node '/var/lib/spawnfile/moltnet/nodes/research.json' &"); @@ -938,8 +946,9 @@ describe("renderEntrypoint", () => { linkPath: mountPath, mode: "mutable", mount: "./resources/cache", - sharing: "per_agent" - } + sharing: "per_agent", + resolvedIdentity: "sha256:replacement-proof" + } as any ] ); await mkdir(path.dirname(plan.instancePaths.configPath), { recursive: true }); @@ -954,8 +963,60 @@ describe("renderEntrypoint", () => { await expect(readFile(proofPath, "utf8")).resolves.toBe("volume-ok"); await expect(lstat(mountPath).then((stats) => stats.isSymbolicLink())).resolves.toBe(true); await expect(readlink(mountPath)).resolves.toBe(backingPath); + await expect(readFile(path.join(backingPath, ".spawnfile-resource-identity"), "utf8")).resolves.toBe("sha256:replacement-proof\n"); + await execFile("bash", [entrypoint], { cwd: directory }); + await writeFile(path.join(backingPath, ".spawnfile-resource-identity"), "wrong\n"); + await expect(execFile("bash", [entrypoint], { cwd: directory })).rejects.toThrow(/replacement sentinel mismatch/u); + await rm(path.join(backingPath, ".spawnfile-resource-identity")); + await expect(execFile("bash", [entrypoint], { cwd: directory })).rejects.toThrow(/nonempty without an authenticated replacement sentinel/u); }); + it("authenticates the compiler bootstrap marker copied into a fresh named volume", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-volume-bootstrap-image-")); + temporaryDirectories.push(directory); + const tag = `spawnfile-volume-bootstrap-${Date.now().toString(36)}`; + const volume = `${tag}-volume`; + const hostileVolumes = [`${tag}-wrong`, `${tag}-extra`, `${tag}-link`]; + const backingPath = "/var/lib/spawnfile/resources/edition-state"; + const plan: RuntimeTargetPlan = { + ...createRuntimePlan("test-runtime"), + runtimeName: "daimon", + instancePaths: { configPath: "/runtime/config.json", workspacePath: "/runtime/workspace" }, + meta: { configFileName: "config.json", instancePaths: { configPathTemplate: "", workspacePathTemplate: "" }, standaloneBaseImage: "node:24-bookworm-slim", startCommand: ["sh", "-c", `test -f '${backingPath}/.spawnfile-resource-identity' && test ! -e '${backingPath}/.spawnfile-volume-init'`], systemDeps: [] }, + resources: [{ backingPath, id: "edition-state", kind: "volume", linkPath: "/runtime/workspace/edition-state", mode: "mutable", mount: "./edition-state", resolvedIdentity: `sha256:${"a".repeat(64)}`, sharing: "team" } as any] + }; + const { renderEntrypoint } = await loadRenderModule({}); + await writeFile(path.join(directory, "entrypoint.sh"), renderEntrypoint([plan], []), { mode: 0o755 }); + await writeFile(path.join(directory, "Dockerfile"), [ + "FROM node:24-bookworm-slim", + "RUN groupadd --gid 2000 spawnfile && useradd --uid 2000 --gid 2000 --create-home spawnfile", + "COPY --chmod=755 entrypoint.sh /entrypoint.sh", + "RUN mkdir -p /runtime/workspace /runtime && printf '{}\\n' > /runtime/config.json && chown -R 2000:2000 /runtime", + `RUN ${createStateOwnershipCommand([plan], [backingPath])} && chown -R 2000:2000 '${backingPath}' /runtime`, + "USER spawnfile", + "ENTRYPOINT [\"/entrypoint.sh\",\"--spawnfile-runtime-identity\",\"2000\",\"2000\"]" + ].join("\n")); + try { + await execFile("docker", ["build", "--pull=false", "--tag", tag, "."], { cwd: directory, timeout: 60_000 }); + await execFile("docker", ["volume", "create", volume]); + await expect(execFile("docker", ["run", "--rm", "--mount", `type=volume,source=${volume},target=${backingPath}`, tag], { timeout: 30_000 })).resolves.toBeDefined(); + await expect(execFile("docker", ["run", "--rm", "--mount", `type=volume,source=${volume},target=${backingPath}`, tag], { timeout: 30_000 })).resolves.toBeDefined(); + for (const hostile of hostileVolumes) await execFile("docker", ["volume", "create", hostile]); + await execFile("docker", ["run", "--rm", "-v", `${hostileVolumes[0]}:/state`, "alpine:3.22", "sh", "-ceu", "printf wrong > /state/.spawnfile-volume-init; chown -R 2000:2000 /state; chmod 600 /state/.spawnfile-volume-init"]); + await execFile("docker", ["run", "--rm", "-v", `${hostileVolumes[1]}:/state`, "alpine:3.22", "sh", "-ceu", "printf 'spawnfile.volume-bootstrap.v1\\n' > /state/.spawnfile-volume-init; touch /state/extra; chown -R 2000:2000 /state; chmod 600 /state/.spawnfile-volume-init"]); + await execFile("docker", ["run", "--rm", "-v", `${hostileVolumes[2]}:/state`, "alpine:3.22", "sh", "-ceu", "printf 'spawnfile.volume-bootstrap.v1\\n' > /state/target; chown -R 2000:2000 /state; chmod 600 /state/target; ln -s target /state/.spawnfile-volume-init"]); + const wrongUidVolume = `${tag}-wrong-uid`; + hostileVolumes.push(wrongUidVolume); + await execFile("docker", ["volume", "create", wrongUidVolume]); + await execFile("docker", ["run", "--rm", "-v", `${wrongUidVolume}:/state`, "alpine:3.22", "sh", "-ceu", "printf 'spawnfile.volume-bootstrap.v1\\n' > /state/.spawnfile-volume-init; chown 2000:2000 /state; chown 1001:1001 /state/.spawnfile-volume-init; chmod 600 /state/.spawnfile-volume-init"]); + for (const hostile of hostileVolumes) await expect(execFile("docker", ["run", "--rm", "--mount", `type=volume,source=${hostile},target=${backingPath}`, tag], { timeout: 30_000 })).rejects.toThrow(/bootstrap marker mismatch|nonempty without/u); + } finally { + await execFile("docker", ["volume", "rm", "--force", volume]).catch(() => undefined); + for (const hostile of hostileVolumes) await execFile("docker", ["volume", "rm", "--force", hostile]).catch(() => undefined); + await execFile("docker", ["image", "rm", "--force", tag]).catch(() => undefined); + } + }, 90_000); + it("clones git resources into the declared mount path before starting the runtime", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-git-resource-")); temporaryDirectories.push(directory); diff --git a/src/compiler/containerArtifactsRender.ts b/src/compiler/containerArtifactsRender.ts index 5484a920..d8b0683a 100644 --- a/src/compiler/containerArtifactsRender.ts +++ b/src/compiler/containerArtifactsRender.ts @@ -25,6 +25,10 @@ import { renderDaimonUidEntrypoint } from "./containerDaimonUidEntrypointRender.js"; import { createStateOwnershipCommand } from "./containerStateOwnershipRender.js"; +import { + RUNTIME_LINK_MATERIALIZER_PATH, + renderRuntimeLinkMaterializer +} from "./containerRuntimeLinkMaterializer.js"; import { MOLTNET_BIN_DIRECTORY, MOLTNET_BINARY_NAMES } from "./moltnetBinaries.js"; import { collectPackagesByManager, @@ -42,7 +46,7 @@ const GATEWAY_PORT_PLACEHOLDER = ""; const WORKSPACE_PLACEHOLDER = ""; const RUNTIME_ROOT_PLACEHOLDER = ""; const PREBUILT_FINAL_SYSTEM_DEPS_BY_RUNTIME: Readonly>> = { - daimon: new Set(["dbus-daemon", "gnome-keyring", "util-linux"]) + daimon: new Set(["bubblewrap", "dbus-daemon", "gnome-keyring", "util-linux"]) }; const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\"'\"'`)}'`; @@ -146,6 +150,7 @@ export const renderDockerfile = async ( const needsGit = runtimePlans.some((plan) => (plan.resources ?? []).some((resource) => resource.kind === "git") ); + const needsBundle = runtimePlans.some((plan) => (plan.resources ?? []).some((resource) => resource.kind === "bundle")); const systemDeps = [ ...new Set([ ...runtimePlans.flatMap((plan) => @@ -156,6 +161,7 @@ export const renderDockerfile = async ( : plan.meta.systemDeps ), ...(needsGit ? ["git"] : []), + ...(needsBundle ? ["tar"] : []), ...(needsJsonEnvWriter ? ["python3"] : []) ]) ].sort(); @@ -211,6 +217,8 @@ export const renderDockerfile = async ( ); } + if (options.hasWorkspaceBundles) lines.push("COPY container/workspace-bundles/ /opt/spawnfile/workspace-bundles/", ""); + for (const recipe of runtimeRecipes) { for (const copyCommand of recipe.copyCommands) { lines.push(copyCommand); @@ -232,9 +240,19 @@ export const renderDockerfile = async ( "COPY container/rootfs/ /", "COPY .env.example /opt/spawnfile/.env.example", 'COPY entrypoint.sh /opt/spawnfile/entrypoint.sh', - `RUN chmod +x /opt/spawnfile/entrypoint.sh${hasDaimon ? ` ${DAIMON_UID_ENTRYPOINT_PATH}` : ""}` + `RUN chmod +x /opt/spawnfile/entrypoint.sh${hasDaimon ? ` ${DAIMON_UID_ENTRYPOINT_PATH}` : ""}${hasDaimon ? " && chmod 711 /opt /opt/spawnfile" : ""}` ); + if (hasDaimon) { + for (const runtimeRoot of [...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .map((plan) => plan.runtimeRoot))].sort()) { + lines.push( + `RUN node ${shellQuote(RUNTIME_LINK_MATERIALIZER_PATH)} ${shellQuote(runtimeRoot)} && rm ${shellQuote(RUNTIME_LINK_MATERIALIZER_PATH)} && test -d ${shellQuote(runtimeRoot)} && test ! -L ${shellQuote(runtimeRoot)} && test -z "$(find -P ${shellQuote(runtimeRoot)} \\( -type l -o ! -user root -o ! -group root \\) -print -quit)" && chmod 711 /opt/spawnfile/runtime-installs ${shellQuote(runtimeRoot)} && find -P ${shellQuote(runtimeRoot)} -type d -exec chmod 711 {} + && find -P ${shellQuote(runtimeRoot)} -type f -perm /111 -exec chmod 555 {} + && find -P ${shellQuote(runtimeRoot)} -type f ! -perm /111 -exec chmod 444 {} + && chmod 555 ${shellQuote(path.posix.join(runtimeRoot, "daimon-start.sh"))}` + ); + } + } + const postRootfsCommands = [ ...new Set(runtimePlans.flatMap((plan) => plan.meta.postRootfsCommands ?? [])) ]; @@ -268,6 +286,13 @@ export const renderDockerfile = async ( lines.push(`EXPOSE ${exposedPorts.join(" ")}`); } + if (hasDaimon) { + const daimonConfig = runtimePlans.find((plan) => plan.runtimeName === "daimon")?.instancePaths.configPath; + if (!daimonConfig) throw new Error("Daimon container plan is missing its runtime config path"); + const healthProgram = "const fs=require('node:fs');const c=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));fetch(`http://127.0.0.1:${c.host.port}/healthz`).then(async r=>{if(!r.ok||JSON.stringify(await r.json())!==JSON.stringify({status:'ok'}))process.exit(1)}).catch(()=>process.exit(1))"; + lines.push(`HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=12 CMD ["node","-e",${JSON.stringify(healthProgram)},${JSON.stringify(daimonConfig)}]`); + } + lines.push(hasDaimon ? "USER root" : "USER spawnfile"); lines.push(hasDaimon ? `ENTRYPOINT ["${DAIMON_UID_ENTRYPOINT_PATH}"]` @@ -347,6 +372,10 @@ export const createRootfsFiles = ( ); return runtimePlans.some((plan) => plan.runtimeName === "daimon") ? [{ + content: renderRuntimeLinkMaterializer(), + mode: 0o600, + path: `${CONTAINER_ROOTFS_ROOT}${RUNTIME_LINK_MATERIALIZER_PATH}` + }, { content: renderDaimonUidEntrypoint(runtimePlans, persistentMountPaths, moltnet), mode: 0o755, path: `${CONTAINER_ROOTFS_ROOT}${DAIMON_UID_ENTRYPOINT_PATH}` diff --git a/src/compiler/containerArtifactsResources.test.ts b/src/compiler/containerArtifactsResources.test.ts index ea318d2e..86cb747d 100644 --- a/src/compiler/containerArtifactsResources.test.ts +++ b/src/compiler/containerArtifactsResources.test.ts @@ -72,10 +72,43 @@ describe("container workspace resources", () => { const resources = result.report.workspace_resources ?? []; expect(resources).toHaveLength(2); expect(new Set(resources.map((resource) => resource.backing_path)).size).toBe(1); + expect(new Set(resources.map((resource) => resource.volume_name)).size).toBe(1); + expect(new Set(resources.map((resource) => resource.resolved_identity)).size).toBe(1); expect(resources.map((resource) => resource.link_path).sort()).toEqual([ "/var/lib/spawnfile/instances/openclaw/agent-analyst/home/.openclaw/workspace/shared", "/var/lib/spawnfile/instances/openclaw/agent-writer/home/.openclaw/workspace/shared" ]); expect(resources.every((resource) => resource.sharing === "team")).toBe(true); + expect(resources.every((resource) => resource.replacement_sentinel?.result === "verified_on_startup")).toBe(true); + expect((result.report.persistent_mounts ?? []).filter((mount) => mount.id.startsWith("workspace-resource-"))).toHaveLength(1); + }); + + it("keeps per-agent volume identities and names isolated", async () => { + const resource = { id: "private", kind: "volume" as const, mode: "mutable" as const, mount: "./private", scope: { kind: "team" as const, key: "/tmp/lab/Spawnfile", name: "lab" }, sharing: "per_agent" as const }; + const agents = [createAgentNode("analyst", [resource]), createAgentNode("writer", [resource])]; + const compiled = await Promise.all(agents.map(async (value) => ({ emittedFiles: (await openClawAdapter.compileAgent(value)).files, kind: "agent" as const, runtimeName: "openclaw", slug: value.name, value }))); + const result = await createContainerArtifacts(createPlan(["openclaw"]), compiled); + const resources = result.report.workspace_resources ?? []; + expect(resources).toHaveLength(2); + expect(new Set(resources.map((entry) => entry.backing_path)).size).toBe(2); + expect(new Set(resources.map((entry) => entry.volume_name)).size).toBe(2); + expect(new Set(resources.map((entry) => entry.resolved_identity)).size).toBe(2); + }); + + it("namespaces every writable workspace volume by candidate run", async () => { + const resource = { id: "edition", kind: "volume" as const, mode: "mutable" as const, mount: "./edition", scope: { kind: "team" as const, key: "/tmp/lab/Spawnfile", name: "lab" }, sharing: "team" as const }; + const agent = createAgentNode("writer", [resource]); const compiled = [{ emittedFiles: (await openClawAdapter.compileAgent(agent)).files, kind: "agent" as const, runtimeName: "openclaw", slug: agent.name, value: agent }]; + const previous = process.env.NOOPOLIS_RUN_ID; const names: string[] = []; + try { for (const runId of ["live-r28", "candidate-r29"]) { process.env.NOOPOLIS_RUN_ID = runId; names.push((await createContainerArtifacts(createPlan(["openclaw"]), compiled)).report.workspace_resources![0]!.volume_name!); } } + finally { if (previous === undefined) delete process.env.NOOPOLIS_RUN_ID; else process.env.NOOPOLIS_RUN_ID = previous; } + expect(names[0]).not.toBe(names[1]); expect(names).toEqual([expect.stringContaining("live-r28"), expect.stringContaining("candidate-r29")]); + }); + + it("rejects incompatible team declarations that collide on one backing path", async () => { + const resource = { id: "shared", kind: "volume" as const, mount: "./shared", scope: { kind: "team" as const, key: "/tmp/lab/Spawnfile", name: "lab" }, sharing: "team" as const }; + const analyst = createAgentNode("analyst", [{ ...resource, mode: "mutable" as const }]); + const writer = createAgentNode("writer", [{ ...resource, mode: "readonly" as const }]); + const compiled = await Promise.all([analyst, writer].map(async (value) => ({ emittedFiles: (await openClawAdapter.compileAgent(value)).files, kind: "agent" as const, runtimeName: "openclaw", slug: value.name, value }))); + await expect(createContainerArtifacts(createPlan(["openclaw"]), compiled)).rejects.toThrow(/incompatible mode, sharing, or owner/u); }); }); diff --git a/src/compiler/containerDaimonBrokerRender.test.ts b/src/compiler/containerDaimonBrokerRender.test.ts new file mode 100644 index 00000000..7e75aa9f --- /dev/null +++ b/src/compiler/containerDaimonBrokerRender.test.ts @@ -0,0 +1,98 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { + renderDaimonBrokerProvisioning, + renderDaimonWorkspaceResourceSecurity +} from "./containerDaimonBrokerRender.js"; + +const execFile = promisify(execFileCallback); +const uid = process.getuid?.() ?? 501; +const gid = process.getgid?.() ?? 20; +const owners = { linkUid: uid, linkGid: gid, readonlyUid: uid, readonlyGid: gid, privilegedUid: uid, privilegedGid: gid }; + +describe("Daimon broker registration ABI", () => { + it("renders the manifest-declared native ABI version", () => { + const plan = { + runtimeName: "daimon", + engineByNodeId: { "agent:grok": "grok" }, + instancePaths: { workspacePath: "/workspace" } + } as unknown as Parameters[0][number]; + expect(renderDaimonBrokerProvisioning([plan]).join("\n")) + .toContain("record.writeUInt32LE(2, 0)"); + }); +}); + +const validate = async (root: string, resource: Parameters[0][number], linkPath: string, expectedOwners = owners, infoOverride: Record = {}, pathOverrides:Record>={},secondFstatOverride:Record={}) => { + const program = [ + "const fs=require('node:fs');", + "const originalLstat=fs.lstatSync.bind(fs),overrides=JSON.parse(process.argv[3]);fs.lstatSync=(target)=>Object.assign(originalLstat(target),overrides[target]??{});", + "const originalOpen=fs.openSync.bind(fs),fdPaths=new Map();fs.openSync=(target,...args)=>{const fd=originalOpen(target,...args);fdPaths.set(fd,target);return fd;};const originalFstat=fs.fstatSync.bind(fs),second=JSON.parse(process.argv[4]);let fstatCalls=0;fs.fstatSync=(fd)=>Object.assign(originalFstat(fd),overrides[fdPaths.get(fd)]??{},++fstatCalls===2?second:{});", + ...renderDaimonWorkspaceResourceSecurity([resource], expectedOwners, `${root}/`), + "const info=fs.lstatSync(process.argv[1]);Object.assign(info,JSON.parse(process.argv[2]));validateResourceLink(process.argv[1],info);" + ].join("\n"); + return execFile(process.execPath, ["-e", program, linkPath, JSON.stringify(infoOverride),JSON.stringify(pathOverrides),JSON.stringify(secondFstatOverride)]); +}; + +describe("Daimon worker workspace resource link guard", () => { + it("accepts only the exact compiler declaration and remains restart-idempotent", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-resource-guard-")); + try { + const backing = path.join(root, "readonly"), linkPath = path.join(root, "workspace-link"); + await mkdir(backing); await chmod(backing, 0o555); await symlink(backing, linkPath); + const resource = { backingPath: backing, kind: "git" as const, linkPath, mode: "readonly" as const, resolvedIdentity: null }; + await expect(validate(root, resource, linkPath)).resolves.toBeDefined(); + await expect(validate(root, resource, linkPath)).resolves.toBeDefined(); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + it("rejects undeclared, substituted, relative, outside, linked, and unsafe backing identities", async () => { + const cases = ["undeclared", "substituted", "relative", "outside", "traversal", "linked", "owner", "mode", "type"] as const; + for (const fault of cases) { + const root = await mkdtemp(path.join(os.tmpdir(), `spawnfile-resource-${fault}-`)); + try { + const backing = path.join(root, "backing"), other = path.join(root, "other"), linkPath = path.join(root, "link"); + if (fault === "type") await writeFile(backing, "file"); else { await mkdir(backing); await chmod(backing, fault === "mode" ? 0o755 : 0o555); } + await mkdir(other); await chmod(other, 0o555); + const traversal=`${root}/../../etc`;await symlink(fault === "relative" ? "backing" : fault === "substituted" ? other : fault === "outside" ? os.tmpdir() : fault==="traversal"?traversal:backing, linkPath); + const resource = { backingPath: fault==="traversal"?traversal:backing, kind: "git" as const, linkPath: fault === "undeclared" ? `${linkPath}-other` : linkPath, mode: "readonly" as const, resolvedIdentity: null }; + await expect(validate(root, resource, linkPath, fault === "owner" ? { ...owners, linkUid: uid + 1 } : owners, fault === "linked" ? { nlink: 2 } : {})).rejects.toThrow(); + } finally { await rm(root, { recursive: true, force: true }); } + } + }); + + it("rejects missing, wrong, and symbolic volume identity sentinels", async () => { + for (const fault of ["missing", "wrong", "link","fifo"] as const) { + const root = await mkdtemp(path.join(os.tmpdir(), `spawnfile-volume-${fault}-`)); + try { + const backing = path.join(root, "volume"), linkPath = path.join(root, "link"), sentinel = path.join(backing, ".spawnfile-resource-identity"); + await mkdir(backing); await chmod(backing, 0o755); await symlink(backing, linkPath); + if (fault === "wrong") await writeFile(sentinel, "wrong\n", { mode: 0o644 }); + if (fault === "link") await symlink(path.join(root, "missing"), sentinel); + if(fault==="fifo")await execFile("mkfifo",[sentinel]); + const resource = { backingPath: backing, kind: "volume" as const, linkPath, mode: "mutable" as const, resolvedIdentity: `sha256:${"a".repeat(64)}` }; + await expect(validate(root, resource, linkPath)).rejects.toThrow(); + } finally { await rm(root, { recursive: true, force: true }); } + } + }); + + it("accepts only declared volume preboot and post-materialization owners with the privileged identity sentinel",async()=>{ + const root=await mkdtemp(path.join(os.tmpdir(),"spawnfile-volume-lifecycle-")); + try{ + const expectedOwners={linkUid:2000,linkGid:2000,readonlyUid:2000,readonlyGid:2000,privilegedUid:0,privilegedGid:0};const link={uid:2000,gid:2000,nlink:1}; + const resolvedIdentity=`sha256:${"a".repeat(64)}`; + for(const name of ["agent-a-data","agent-b-data","agent-c-data","agent-d-staging","agent-e-data","team-state"]){const backing=path.join(root,name),linkPath=path.join(root,`${name}-link`),sentinel=path.join(backing,".spawnfile-resource-identity");await mkdir(backing);await chmod(backing,0o755);await writeFile(sentinel,`${resolvedIdentity}\n`,{mode:0o644});await symlink(backing,linkPath);const resource={backingPath:backing,kind:"volume" as const,linkPath,mode:"mutable" as const,resolvedIdentity};const identity={[sentinel]:{uid:0,gid:0,nlink:1,mode:0o100644}};await expect(validate(root,resource,linkPath,expectedOwners,link,{...identity,[backing]:{uid:0,gid:0,mode:0o40755}})).resolves.toBeDefined();await expect(validate(root,resource,linkPath,expectedOwners,link,{...identity,[backing]:{uid:2000,gid:2000,mode:0o40755}})).resolves.toBeDefined();} + const backing=path.join(root,"agent-a-data"),linkPath=path.join(root,"agent-a-data-link"),sentinel=path.join(backing,".spawnfile-resource-identity"),resource={backingPath:backing,kind:"volume" as const,linkPath,mode:"mutable" as const,resolvedIdentity},identity={[sentinel]:{uid:0,gid:0,nlink:1,mode:0o100644}}; + for(const backingIdentity of [{uid:2001,gid:2000,mode:0o40755},{uid:2000,gid:0,mode:0o40755},{uid:2000,gid:2000,mode:0o40750}])await expect(validate(root,resource,linkPath,expectedOwners,link,{...identity,[backing]:backingIdentity})).rejects.toThrow(); + await expect(validate(root,resource,linkPath,expectedOwners,link,{[sentinel]:{uid:2000,gid:2000,nlink:1,mode:0o100644},[backing]:{uid:2000,gid:2000,mode:0o40755}})).rejects.toThrow(); + const changedStats:Record[]=[{ino:999999},{size:0},{mtimeMs:0},{ctimeMs:0}]; + for(const changed of changedStats)await expect(validate(root,resource,linkPath,expectedOwners,link,{...identity,[backing]:{uid:2000,gid:2000,mode:0o40755}},changed)).rejects.toThrow(); + for(const suffix of [" ","\n","extra"]){await writeFile(sentinel,`${resolvedIdentity}\n${suffix}`,{mode:0o644});await expect(validate(root,resource,linkPath,expectedOwners,link,{...identity,[backing]:{uid:2000,gid:2000,mode:0o40755}})).rejects.toThrow();} + }finally{await rm(root,{recursive:true,force:true});} + }); +}); diff --git a/src/compiler/containerDaimonBrokerRender.ts b/src/compiler/containerDaimonBrokerRender.ts new file mode 100644 index 00000000..346f8507 --- /dev/null +++ b/src/compiler/containerDaimonBrokerRender.ts @@ -0,0 +1,109 @@ +import path from "node:path"; + +import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; + +export const DAIMON_ORGANIZATION_UID = 2_000; +export const DAIMON_BROKER_UID = 2_100; +export const DAIMON_FIRST_WORKER_UID = 2_200; +export const DAIMON_BROKER_EXECUTABLE = "/opt/daimon/bin/daimon-engine-broker"; +export const DAIMON_BROKER_REGISTRATIONS = "/etc/daimon-engine-broker/registrations.bin"; +export const DAIMON_BROKER_SOCKET = "/run/daimon-engine-broker/control.sock"; +export const DAIMON_BROKER_BACKEND_SOCKET = "/run/daimon-engine-broker/backend.sock"; +export const DAIMON_BROKER_LAUNCHER_SOCKET = "/run/daimon-engine-broker/launcher.sock"; +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"; + +interface WorkspaceSecurityResource { + backingPath: string; + kind: "bundle" | "git" | "volume"; + linkPath: string; + mode: "mutable" | "readonly"; + resolvedIdentity: string | null; +} + +export const renderDaimonWorkspaceResourceSecurity = ( + resources: WorkspaceSecurityResource[], + owners = { linkUid: 2_000, linkGid: 2_000, readonlyUid: 2_000, readonlyGid: 2_000, privilegedUid: 0, privilegedGid: 0 }, + resourceRoot = "/var/lib/spawnfile/resources/" +): string[] => [ + `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 nodeSlug = (nodeId: string): string => nodeId.replace(/^agent:/u, "") + .toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, ""); + +export const resolveDaimonGrokRegistrations = (plans: RuntimeTargetPlan[]) => plans + .filter((plan) => plan.runtimeName === "daimon") + .flatMap((plan) => Object.entries(plan.engineByNodeId ?? {}) + .filter(([, engine]) => engine === "grok") + .map(([agentId]) => ({ + agentId, + workspace: path.posix.join(plan.instancePaths.workspacePath, "agents", nodeSlug(agentId)) + }))) + .sort((left, right) => left.agentId.localeCompare(right.agentId)) + .map((entry, slot) => ({ + ...entry, + home: path.posix.join(DAIMON_WORKER_ROOT, String(DAIMON_FIRST_WORKER_UID + slot)), + slot, + uid: DAIMON_FIRST_WORKER_UID + slot + })); + +export const renderDaimonBrokerProvisioning = (plans: RuntimeTargetPlan[]): string[] => { + const registrations = resolveDaimonGrokRegistrations(plans); + if (registrations.length === 0) return []; + const workspaceResources = plans + .filter((plan) => plan.runtimeName === "daimon") + .flatMap((plan) => plan.resources ?? []) + .map((resource) => ({ + backingPath: resource.backingPath, + kind: resource.kind, + linkPath: resource.linkPath, + mode: resource.mode, + resolvedIdentity: "resolvedIdentity" in resource && typeof resource.resolvedIdentity === "string" + ? resource.resolvedIdentity + : null + })) + .sort((left, right) => left.linkPath.localeCompare(right.linkPath)); + const program = [ + "const crypto = require('node:crypto'); const fs = require('node:fs');", + `const registrations = ${JSON.stringify(registrations)};`, + "const executable = '/usr/local/bin/grok';", + "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.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);`, + `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; } };`, + "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');", + `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); }`, + "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); }`, + ...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']);`, + "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);" + ].join("\n"); + return [ + "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'", + program, + "SPAWNFILE_DAIMON_BROKER_PROVISION" + ]; +}; diff --git a/src/compiler/containerDaimonOwnershipGuardRender.ts b/src/compiler/containerDaimonOwnershipGuardRender.ts new file mode 100644 index 00000000..557d6964 --- /dev/null +++ b/src/compiler/containerDaimonOwnershipGuardRender.ts @@ -0,0 +1,183 @@ +import path from "node:path"; +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import { VOLUME_BOOTSTRAP_MARKER, VOLUME_BOOTSTRAP_MARKER_CONTENT } from "./containerVolumeBootstrap.js"; +const SPAWNFILE_PRIVATE_STATE_ROOT = "/var/lib/spawnfile"; +export const renderDaimonOwnershipProgram = ( + opaqueTargets: string[], + opaqueDescendantRoots: string[], + immutableRuntimeRoots: string[], + volumeIdentityFiles: Array<{ path: string; identity: string }>, + privateDirectories: string[], + privateFiles: string[], + privateModeDirectories: string[], + creatablePrivateDirectories: Array<{ anchor: string; target: string }> +): string => [ + "const fs = require('node:fs');", + "const constants = fs.constants;", + "const uid = Number(process.argv[2]);", + "const roots = process.argv.slice(3);", + "const fail = (message) => { process.stderr.write(`Daimon ownership guard: ${message}\\n`); process.exit(1); };", + `const opaquePaths = new Set(${JSON.stringify(opaqueTargets)});`, + `const opaqueDescendantRoots = new Set(${JSON.stringify(opaqueDescendantRoots)});`, + `const immutableRuntimeRoots = ${JSON.stringify(immutableRuntimeRoots)};`, + `const volumeIdentityFiles = ${JSON.stringify(volumeIdentityFiles)};`, + "const volumeIdentityPaths = new Set(volumeIdentityFiles.map((entry) => entry.path)); if (volumeIdentityPaths.size !== volumeIdentityFiles.length) fail('duplicate volume identity path');", + `const privateDirectories = ${JSON.stringify(privateDirectories)};`, + `const privateFiles = ${JSON.stringify(privateFiles)};`, + `const privateModeDirectories = ${JSON.stringify(privateModeDirectories)};`, + `const creatablePrivateDirectories = ${JSON.stringify(creatablePrivateDirectories)};`, + "if (!Number.isSafeInteger(uid) || uid < 1 || roots.length === 0) fail('invalid compiler-authored roots');", + "const decodeMountPath = (value) => value.replace(/\\\\([0-7]{3})/g, (_, octal) => String.fromCharCode(Number.parseInt(octal, 8)));", + "const mountOptionsFor = (target) => {", + " const matches = fs.readFileSync('/proc/self/mountinfo', 'utf8').trim().split('\\n').map((line) => line.split(' ')).filter((parts) => parts.length > 5).map((parts) => ({ point: decodeMountPath(parts[4]), options: parts[5].split(',') })).filter((mount) => target === mount.point || target.startsWith(`${mount.point}/`));", + " return matches.sort((left, right) => right.point.length - left.point.length)[0]?.options ?? [];", + "};", + "const openDirectoryPath = (target) => {", + " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored root');", + " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " try {", + " for (const segment of target.slice(1).split('/')) {", + " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored root');", + " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " fs.closeSync(fd); fd = next;", + " }", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory()) fail('root is not a directory');", + " if (mountOptionsFor(target).includes('ro')) fail('root is read-only');", + " return fd;", + " } catch (error) { try { fs.closeSync(fd); } catch {} fail('root has a symbolic-link or unavailable path component'); }", + "};", + "const openRegularFilePath = (target) => {", + " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored file');", + " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " try {", + " const segments = target.slice(1).split('/');", + " for (const [index, segment] of segments.entries()) {", + " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored file');", + " const isFile = index === segments.length - 1;", + " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (isFile ? 0 : constants.O_DIRECTORY));", + " fs.closeSync(fd); fd = next;", + " }", + " const info = fs.fstatSync(fd);", + " if (!info.isFile() || info.nlink !== 1) fail('compiler-authored file is not one regular file');", + " if (mountOptionsFor(target).includes('ro')) fail('compiler-authored file is read-only');", + " return fd;", + " } catch (error) { try { fs.closeSync(fd); } catch {} fail('file has a symbolic-link or unavailable path component'); }", + "};", + "const secureFixedTraversalAncestor = (target) => {", + " const fd = openDirectoryPath(target);", + " try {", + " const info = fs.fstatSync(fd);", + " const mode = info.mode & 0o777;", + " if (info.uid !== 0 || info.gid !== 0 || (mode !== 0o775 && mode !== 0o755 && mode !== 0o711)) fail('fixed state ancestor has an unexpected preimage');", + " fs.fchmodSync(fd, 0o711);", + " const secured = fs.fstatSync(fd);", + " if (secured.uid !== 0 || secured.gid !== 0 || (secured.mode & 0o777) !== 0o711) fail('fixed state ancestor traversal mode did not apply');", + " } finally { fs.closeSync(fd); }", + "};", + "const secureSharedStateAncestor = (target) => {", + " const fd = openDirectoryPath(target);", + " try {", + " const info = fs.fstatSync(fd);", + " const mode = info.mode & 0o777;", + " const current = info.uid === 0 && info.gid === 0 && mode === 0o711;", + " const legacy = info.uid === uid && info.gid === uid && mode === 0o700;", + " if (!current && !legacy) fail('shared state ancestor has an unexpected preimage');", + " fs.fchownSync(fd, 0, 0); fs.fchmodSync(fd, 0o711);", + " const secured = fs.fstatSync(fd);", + " if (secured.uid !== 0 || secured.gid !== 0 || (secured.mode & 0o777) !== 0o711) fail('shared state ancestor traversal mode did not apply');", + " } finally { fs.closeSync(fd); }", + "};", + "const createPrivateDirectoryPath = ({ anchor, target }) => {", + " if (target !== anchor && !target.startsWith(`${anchor}/`)) fail('private directory escapes its compiler-authored anchor');", + " let fd = openDirectoryPath(anchor);", + " const anchorInfo = fs.fstatSync(fd);", + " const createdFds = [];", + " let preparationError;", + " let current = anchor;", + " try {", + " const relative = target.slice(anchor.length).replace(/^\\//, '');", + " for (const segment of relative ? relative.split('/') : []) {", + " if (!segment || segment === '.' || segment === '..') throw new Error('unsafe');", + " fs.fchownSync(fd, 0, 0);", + " const childPath = `/proc/self/fd/${fd}/${segment}`;", + " try { fs.mkdirSync(childPath, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }", + " const next = fs.openSync(childPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", + " createdFds.push(fd); fd = next; current = `${current}/${segment}`;", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory() || mountOptionsFor(current).includes('ro')) throw new Error('unsafe-or-read-only');", + " }", + " createdFds.push(fd); fd = -1;", + " for (const ownedFd of createdFds.slice(1).reverse()) { fs.fchownSync(ownedFd, 0, 0); fs.fchmodSync(ownedFd, 0o700); fs.fchownSync(ownedFd, uid, uid); }", + " } catch (error) { preparationError = error.code ?? error.message ?? 'unknown'; } finally { const anchorFd = createdFds[0] ?? fd; if (anchorFd >= 0) try { fs.fchownSync(anchorFd, anchorInfo.uid, anchorInfo.gid); } catch { preparationError = 'restore-anchor-ownership'; }; if (fd >= 0) try { fs.closeSync(fd); } catch {}; for (const ownedFd of createdFds) try { fs.closeSync(ownedFd); } catch {} }", + " if (preparationError) fail(`unable to prepare compiler-authored private directory: ${preparationError}`);", + "};", + "const ownTree = (fd, device, currentPath) => {", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory() || info.dev !== device) return;", + " fs.fchownSync(fd, uid, uid);", + " for (const entry of fs.readdirSync(`/proc/self/fd/${fd}`, { withFileTypes: true })) {", + " const childPath = `${currentPath}/${entry.name}`;", + " if (opaquePaths.has(childPath) || opaqueDescendantRoots.has(childPath) || volumeIdentityPaths.has(childPath)) continue;", + " let child;", + " try { child = fs.openSync(`/proc/self/fd/${fd}/${entry.name}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (entry.isDirectory() ? constants.O_DIRECTORY : 0)); } catch { fail('state tree contains a symbolic link or unavailable entry'); }", + " try { const childInfo = fs.fstatSync(child); if (childInfo.dev !== device || mountOptionsFor(childPath).includes('ro')) continue; if (childInfo.isDirectory()) ownTree(child, device, childPath); else if (childInfo.isFile() && childInfo.nlink === 1) fs.fchownSync(child, uid, uid); else if (!childInfo.isFile()) fail('state tree contains an unsupported entry'); } finally { fs.closeSync(child); }", + " }", + "};", + "const validateImmutableTree = (fd, device, currentPath) => {", + " const info = fs.fstatSync(fd);", + " if (!info.isDirectory() || info.dev !== device || info.uid !== 0 || info.gid !== 0 || (info.mode & 0o777) !== 0o711) fail('immutable runtime directory is unsafe');", + " for (const entry of fs.readdirSync(`/proc/self/fd/${fd}`, { withFileTypes: true })) {", + " let child; try { child = fs.openSync(`/proc/self/fd/${fd}/${entry.name}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (entry.isDirectory() ? constants.O_DIRECTORY : 0)); } catch { fail('immutable runtime contains a symbolic link or unavailable entry'); }", + " try { const childInfo = fs.fstatSync(child); if (childInfo.dev !== device || childInfo.uid !== 0 || childInfo.gid !== 0) fail('immutable runtime entry is unsafe'); if (childInfo.isDirectory()) validateImmutableTree(child, device, `${currentPath}/${entry.name}`); else if (!childInfo.isFile() || childInfo.nlink !== 1 || (childInfo.mode & 0o222) !== 0) fail('immutable runtime contains an unsupported entry'); } finally { fs.closeSync(child); }", + " }", + "};", + "const securePrivateDirectory = (fd) => {", + " try {", + " fs.fchownSync(fd, 0, 0);", + " fs.fchmodSync(fd, 0o700);", + " fs.fchownSync(fd, uid, uid);", + " } catch {", + " try { fs.fchownSync(fd, uid, uid); } catch {}", + " fail('unable to secure private directory');", + " }", + " const info = fs.fstatSync(fd);", + " if (info.uid !== uid || info.gid !== uid || (info.mode & 0o777) !== 0o700) fail('private directory ownership or mode did not apply');", + "};", + "const securePrivateFile = (fd) => {", + " try {", + " fs.fchownSync(fd, 0, 0);", + " fs.fchmodSync(fd, 0o600);", + " fs.fchownSync(fd, uid, uid);", + " } catch {", + " try { fs.fchownSync(fd, uid, uid); } catch {}", + " fail('unable to secure private file');", + " }", + " const info = fs.fstatSync(fd);", + " if (info.uid !== uid || info.gid !== uid || (info.mode & 0o777) !== 0o600) fail('private file ownership or mode did not apply');", + "};", + `const volumeBootstrapMarker = ${JSON.stringify(VOLUME_BOOTSTRAP_MARKER)}, volumeBootstrapContent = ${JSON.stringify(`${VOLUME_BOOTSTRAP_MARKER_CONTENT}\n`)};`, + "const verifyVolumeIdentityFile = (fd, expected, mode, device) => { const before = fs.fstatSync(fd); if (!before.isFile() || before.nlink !== 1 || before.uid !== 0 || before.gid !== 0 || (before.mode & 0o777) !== mode || before.dev !== device || before.size !== Buffer.byteLength(expected)) fail('volume identity anchor is unsafe'); const bytes = Buffer.alloc(before.size); let offset=0; while(offset { 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 byPath=new Map(); + const resourceRoot="/var/lib/spawnfile/resources"; + for(const resource of runtimePlans.flatMap((plan)=>plan.resources??[])){ + if(resource.kind!=="volume"||!("replacementSentinel" in resource)||typeof resource.replacementSentinel!=="string"||!("resolvedIdentity" in resource)||typeof resource.resolvedIdentity!=="string")continue; + const relative=typeof resource.backingPath==="string"?path.posix.relative(resourceRoot,resource.backingPath):""; + if(typeof resource.backingPath!=="string"||path.posix.normalize(resource.backingPath)!==resource.backingPath||resource.backingPath.includes("//")||!relative||relative.startsWith("../")||path.posix.isAbsolute(relative)||resource.replacementSentinel!==path.posix.join(resource.backingPath,".spawnfile-resource-identity")||!/^sha256:[a-f0-9]{64}$/u.test(resource.resolvedIdentity))throw new Error("invalid compiler-authored volume identity anchor"); + const existing=byPath.get(resource.replacementSentinel);if(existing!==undefined&&existing!==resource.resolvedIdentity)throw new Error("conflicting compiler-authored volume identity anchor");byPath.set(resource.replacementSentinel,resource.resolvedIdentity); + } + return [...byPath].map(([path,identity])=>({path,identity})).sort((left,right)=>left.path.localeCompare(right.path)); +}; diff --git a/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts b/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts new file mode 100644 index 00000000..ee96f204 --- /dev/null +++ b/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it, vi } from "vitest"; +import { execFile as execFileCallback, spawnSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import type { EntrypointOptions } from "./containerEntrypointRender.js"; +import { renderEntrypoint } from "./containerEntrypointRender.js"; +import { + DAIMON_AUTHORIZED_UID_ENV, + DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS, + renderDaimonBrokerSocketWait, + renderDaimonUidEntrypoint, + resolveDaimonVolumeIdentityFiles, + resolveDaimonUidEntrypointOwnershipPlan, + resolveDaimonUidEntrypointStateRoots +} from "./containerDaimonUidEntrypointRender.js"; + +const execFile = promisify(execFileCallback); +const authorizedUid = 2000; + +const daimonPlan: RuntimeTargetPlan = { + engineByNodeId: { "agent:AGY": "agy", "agent:Codex One": "codex", "agent:Grok Two": "grok" }, + envFiles: [], id: "daimon-organization", + instancePaths: { + configPath: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/config.json", + instanceRoot: "/var/lib/spawnfile/instances/daimon/daimon-organization", + workspacePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace" + }, + meta: { configFileName: "config.json", instancePaths: { configPathTemplate: "", workspacePathTemplate: "" }, standaloneBaseImage: "node:24", startCommand: [], systemDeps: [] }, + modelAuthMethods: {}, modelSecretsRequired: [], opaqueMountTargets: ["/var/lib/spawnfile/daimon/agy-unlock-secret"], + runtimeName: "daimon", runtimeRoot: "/opt/daimon", targetFiles: [] +}; + +const serverConfig = "/var/lib/spawnfile/moltnet/servers/local/Moltnet.json"; +const nodeConfig = "/var/lib/spawnfile/moltnet/nodes/agent.json"; +const causalState = "/var/lib/spawnfile/moltnet/servers/local/causal"; +const agyRealm = "/var/lib/spawnfile/daimon/agy-subscription-realm"; +const agyRuntimeHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy"; +const codexEngineHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex-one/.codex"; +const grokEngineHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok-two/.grok"; +const acceptanceStore = "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance"; +const acceptanceStoreMount = { + id: "daimon-organization-acceptance-store", + mount_path: acceptanceStore, + reason: "Daimon organization durable wake acceptance store", + volume_name: "spawnfile-test-daimon-acceptance-store" +}; +const agyRealmMount = { + id: "daimon-agy-subscription-realm", + mount_path: agyRealm, + reason: "Daimon host AGY subscription realm", + volume_name: "spawnfile-test-agy-realm" +}; +const agyRuntimeHomeMount = { + id: "daimon-agy-runtime-home-agy", + mount_path: agyRuntimeHome, + reason: "Daimon AGY subscription runtime home for agent:agy", + volume_name: "spawnfile-test-agy-runtime-home" +}; +const codexEngineHomeMount = { + id: "daimon-engine-home-codex-codex-one", + mount_path: codexEngineHome, + reason: "Daimon codex subscription credential home for agent:Codex One", + volume_name: "spawnfile-test-codex-engine-home" +}; +const grokEngineHomeMount = { + id: "daimon-engine-home-grok-grok-two", + mount_path: grokEngineHome, + reason: "Daimon grok subscription credential home for agent:Grok Two", + volume_name: "spawnfile-test-grok-engine-home" +}; +const moltnetPlans = { + nodePlans: [{ configPath: nodeConfig, networkId: "local" }], + serverPlans: [{ + baseUrl: "http://127.0.0.1:8787", + configPath: serverConfig, + id: "local", + mode: "managed" as const, + name: "Local", + networkId: "local", + port: 8787, + rooms: [], + secretPatches: [], + server: { + auth: { mode: "none" as const }, + listen: { bind: "127.0.0.1", port: 8787 }, + mode: "managed" as const, + store: { kind: "memory" as const } + }, + teamSource: "/fixture/Spawnfile" + }] +} satisfies NonNullable; + + +describe("renderDaimonUidEntrypoint lifecycle",()=>{ + it("repairs a fresh volume and starts through private executable parents as authorized UID 2000", async () => { + const instanceRoot = "/var/lib/spawnfile/instances/daimon/daimon-organization"; + const workspacePath = `${instanceRoot}/workspace`; + const runtimeHomesPath = `${instanceRoot}/runtime-homes`; + const dockerDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-uid-image-")); + const tag = `spawnfile-daimon-uid-${Date.now().toString(36)}`; + const containerName = `${tag}-container`; + const supervisorContainerName = `${tag}-supervisor`; + const volumeName = `${tag}-realm-volume`; + const runtimeHomeVolumeName = `${tag}-agy-runtime-home-volume`; + const codexVolumeName = `${tag}-codex-engine-home-volume`; + const networkVolumeName = `${tag}-moltnet-network-volume`; + const resourceVolumeName=`${tag}-workspace-resource-volume`; + const networkRoot = "/var/lib/spawnfile/moltnet/networks/local"; + const receiptDirectory = `${networkRoot}/daimon-receipts`; + const resourceLink = `${workspacePath}/agents/writer/repos/public`; + const volumeResourceRoot="/var/lib/spawnfile/resources/teams/example/shared",volumeResourceSentinel=`${volumeResourceRoot}/.spawnfile-resource-identity`,volumeResourceIdentity="sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const receiptMoltnetPlans = { + ...moltnetPlans, + nodePlans: [{ ...moltnetPlans.nodePlans[0]!, receiptStorePath: `${receiptDirectory}/agent.json` }] + }; + const plan: RuntimeTargetPlan = { + ...daimonPlan, + runtimeRoot: "/opt/spawnfile/runtime-installs/daimon", + engineByNodeId: undefined, + instancePaths: { + configPath: `${instanceRoot}/daimon/config.json`, + instanceRoot, + workspacePath + }, + meta: { + ...daimonPlan.meta, + configFileName: "daimon/config.json", + startCommand: ["true"], + systemDeps: ["bash", "dbus-daemon", "util-linux"] + }, + persistentMounts: [ + { ...agyRealmMount, volume_name: volumeName }, + { ...agyRuntimeHomeMount, volume_name: runtimeHomeVolumeName }, + { ...codexEngineHomeMount, volume_name: codexVolumeName } + ], + resources: [{ + backingPath: "/var/lib/spawnfile/resources/instances/writer/public", + id: "public", + kind: "git", + linkPath: resourceLink, + mode: "mutable", + mount: "./repos/public", + sharing: "per_agent", + url: "https://example.invalid/public.git" + },{backingPath:volumeResourceRoot,id:"shared",kind:"volume",linkPath:`${workspacePath}/shared`,mode:"mutable",mount:"./shared",sharing:"team",replacementSentinel:volumeResourceSentinel,resolvedIdentity:volumeResourceIdentity} as NonNullable[number]&{replacementSentinel:string;resolvedIdentity:string}], + targetFiles: [ + { content: "{}\n", path: "daimon/config.json" }, + { content: "#!/usr/bin/env bash\nexit 0\n", mode: 0o755, path: "runtime/daimon-start.sh" } + ] + }; + try { + vi.resetModules(); + vi.doMock("../runtime/index.js", () => ({ + createRuntimeInstallRecipe: vi.fn(async () => ({ + baseImage: "node:24-bookworm-slim", + commands: [], + copyCommands: [], + runtimeName: "daimon", + runtimeRoot: "/opt/spawnfile/runtime-installs/daimon" + })) + })); + const { createRootfsFiles, renderDockerfile } = await import("./containerArtifactsRender.js"); + const dockerfile = await renderDockerfile([plan], { + moltnet: receiptMoltnetPlans, + persistentMountPaths: [agyRealm, agyRuntimeHome, codexEngineHome, causalState, networkRoot,volumeResourceRoot] + }); + const stateRoots = resolveDaimonUidEntrypointStateRoots([plan]); + expect(stateRoots).toEqual([runtimeHomesPath, workspacePath]); + for (const stateRoot of stateRoots) { + expect(dockerfile).toContain(`install -d -o root -g root -m 700 '${stateRoot}'`); + } + expect(dockerfile).not.toContain("SPAWNFILE_DAIMON_WRITABLE_ROOTS"); + expect(dockerfile).not.toContain("/untrusted"); + + expect(dockerfile).toContain(`-m 700 '/var/lib/spawnfile/moltnet'`); + expect(dockerfile).toContain("chown root:root '/var/lib/spawnfile' && chmod 711 '/var/lib/spawnfile'"); + expect(dockerfile).toContain(`'${receiptDirectory}'`); + const rootfsFiles = createRootfsFiles( + [plan], + [agyRealm, agyRuntimeHome, codexEngineHome, causalState, networkRoot, volumeResourceRoot], + receiptMoltnetPlans + ); + expect(rootfsFiles.find((file) => file.path.endsWith("daimon-uid-entrypoint.sh"))?.content) + .toContain(resourceLink); + for (const file of rootfsFiles) { + const outputPath = path.join(dockerDirectory, file.path); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, file.content, { encoding: "utf8", mode: file.mode }); + } + const runtimeBinDirectory = path.join( + dockerDirectory, + "container/rootfs/opt/spawnfile/runtime-installs/daimon/bin" + ); + await chmod(path.join(runtimeBinDirectory, "..", "daimon-start.sh"), 0o755); + await mkdir(runtimeBinDirectory, { recursive: true }); + await symlink("../daimon-start.sh", path.join(runtimeBinDirectory, "daimon-runtime")); + for (const configPath of [nodeConfig, serverConfig]) { + const outputPath = path.join(dockerDirectory, "container/rootfs", configPath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, "{}\n", { encoding: "utf8", mode: 0o600 }); + } + await writeFile(path.join(dockerDirectory, ".env.example"), "", "utf8"); + await writeFile( + path.join(dockerDirectory, "supervisor-entrypoint.sh"), + renderEntrypoint([{ + ...plan, + port: 59999, + resources: [], + instancePaths: { configPath: "/tmp/supervisor/config.json", workspacePath: "/tmp/supervisor/workspace" }, + meta: { ...plan.meta, startCommand: ["bash", "-c", "exit 23"] } + }], [], { + hasMoltnet: true, + moltnet: { + nodePlans: [{ ...receiptMoltnetPlans.nodePlans[0]!, receiptStorePath: undefined }], + serverPlans: [] + } + }), + { encoding: "utf8", mode: 0o755 } + ); + await writeFile( + path.join(dockerDirectory, "entrypoint.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `test \"$(id -u)\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}\"`, + "getent passwd \"$(id -u)\" >/dev/null", + "test \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" = 0000000000000000", + "test \"$(stat -c '%u:%a' /opt)\" = 0:711", + "test \"$(stat -c '%u:%a' /opt/spawnfile)\" = 0:711", + "! ls /opt/spawnfile >/dev/null 2>&1", + "test \"$(stat -c '%u:%g:%a' /opt/spawnfile/runtime-installs)\" = 0:0:711", + "test \"$(stat -c '%u:%g:%a' /opt/spawnfile/runtime-installs/daimon)\" = 0:0:711", + "test \"$(stat -c '%u:%g:%a' /opt/spawnfile/runtime-installs/daimon/daimon-start.sh)\" = 0:0:555", + "test ! -L /opt/spawnfile/runtime-installs/daimon/bin/daimon-runtime", + "test \"$(stat -c '%u:%g:%a' /opt/spawnfile/runtime-installs/daimon/bin/daimon-runtime)\" = 0:0:555", + "! ls /opt/spawnfile/runtime-installs >/dev/null 2>&1", + "! ls /opt/spawnfile/runtime-installs/daimon >/dev/null 2>&1", + "test ! -w /opt/spawnfile/runtime-installs/daimon/daimon-start.sh", + "bash /opt/spawnfile/runtime-installs/daimon/daimon-start.sh", + "test \"$(stat -c '%u:%a' /var)\" = 0:711", + "test \"$(stat -c '%u:%a' /var/lib)\" = 0:711", + "! ls /var >/dev/null 2>&1", + "! ls /var/lib >/dev/null 2>&1", + "test \"$(stat -c '%u:%g:%a' '/var/lib/spawnfile')\" = '0:0:711'", + "! ls /var/lib/spawnfile >/dev/null 2>&1", + `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet/servers/local')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%a' '${serverConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, + `test \"$(stat -c '%u:%a' '${nodeConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, + `test \"$(stat -c '%u:%a' '${instanceRoot}/daimon/config.json')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, + `test \"$(stat -c '%u:%a' '${instanceRoot}/daimon')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `node -e \"JSON.parse(require('node:fs').readFileSync('${instanceRoot}/daimon/config.json','utf8'))\"`, + `test \"$(stat -c '%u:%a' '${causalState}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `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:%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`, + `test \"$(stat -c '%u:%g:%a' '${volumeResourceSentinel}')\" = '0:0:644'`, + `test \"$(stat -c '%u:%g:%a' '${volumeResourceRoot}/content')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:\${${DAIMON_AUTHORIZED_UID_ENV}}:644\"`, + `test "$(stat -c '%u:%a' '${agyRuntimeHome}')" = "\${${DAIMON_AUTHORIZED_UID_ENV}}:700"`, + "test \"$(stat -c '%u:%a' '/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex-one/.codex')\" = \"$SPAWNFILE_DAIMON_AUTHORIZED_UID:700\"", + "test -f '/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex-one/.codex/.spawnfile-volume-init'", + "test \"$(stat -c '%u:%a' /untrusted/sentinel)\" = 0:600", + "! cat /untrusted/sentinel >/dev/null 2>&1", + "dbus_root=$(mktemp -d /tmp/spawnfile-dbus.XXXXXX)", + "chmod 700 \"$dbus_root\"", + "dbus_address=unix:path=$dbus_root/bus", + "dbus-daemon --session --fork --nopidfile --address=\"$dbus_address\"", + "dbus-send --bus=\"$dbus_address\" --dest=org.freedesktop.DBus --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null", + `count_file='${agyRealm}/starts'`, + "count=$(cat \"$count_file\" 2>/dev/null || printf 0)", + "printf %s $((count + 1)) > \"$count_file\"", + `receipt_file='${receiptDirectory}/agent.json'`, + "if [ \"$count\" = 0 ]; then printf accepted > \"$receipt_file\"; else test \"$(cat \"$receipt_file\")\" = accepted; fi", + "readiness_file='/run/spawnfile/moltnet-readiness/local-agent.json'", + "if [ \"$count\" = 0 ]; then printf ready > \"$readiness_file\"; else test \"$(cat \"$readiness_file\")\" = ready; fi", + "plugin_link='/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex-one/.codex/plugins/cache/example/link'", + "if [ \"$count\" = 0 ]; then mkdir -p \"$(dirname \"$plugin_link\")\"; ln -s plugin-target \"$plugin_link\"; else test \"$(readlink \"$plugin_link\")\" = plugin-target; fi", + `resource_link='${resourceLink}'`, + "if [ \"$count\" = 0 ]; then mkdir -p \"$(dirname \"$resource_link\")\"; ln -s /var/lib/spawnfile/resources/instances/writer/public \"$resource_link\"; else test \"$(readlink \"$resource_link\")\" = /var/lib/spawnfile/resources/instances/writer/public; fi", + `token_marker='${agyRuntimeHome}/subscription-state'`, + "if [ \"$count\" = 0 ]; then printf enrolled > \"$token_marker\"; else test \"$(cat \"$token_marker\")\" = enrolled; fi", + `printf 'entrypoint uid=%s caps=%s realm=%s start=%s\\n' \"$(id -u)\" \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" \"$(stat -c '%u:%a' '${agyRealm}')\" \"$count\"` + ].join("\n") + "\n", + "utf8" + ); + await writeFile( + path.join(dockerDirectory, "Dockerfile"), + `${dockerfile}\nCOPY --chmod=755 supervisor-entrypoint.sh /supervisor-entrypoint.sh\nRUN install -d -o ${authorizedUid} -g ${authorizedUid} -m 700 /tmp/supervisor/workspace && printf '{}\\n' > /tmp/supervisor/config.json && chown ${authorizedUid}:${authorizedUid} /tmp/supervisor/config.json && chmod 600 /tmp/supervisor/config.json && install -d -o root -g root -m 711 /untrusted && install -o root -g root -m 600 /dev/null /untrusted/sentinel && printf content > '${volumeResourceRoot}/content' && chmod 0644 '${volumeResourceRoot}/content' && chmod 775 /var /var/lib\n`, + "utf8" + ); + await execFile("docker", ["build", "--pull=false", "--tag", tag, "."], { + cwd: dockerDirectory, + timeout: 30_000 + }); + await execFile("docker", ["volume", "create", networkVolumeName]); + await execFile("docker",["volume","create",resourceVolumeName]); + await execFile("docker",["run","--rm","--entrypoint","bash","--mount",`type=volume,source=${resourceVolumeName},target=${volumeResourceRoot},volume-nocopy`,tag,"-ceu",`printf '%s\\n' 'spawnfile.volume-bootstrap.v1' > '${volumeResourceRoot}/.spawnfile-volume-init'; chown 0:0 '${volumeResourceRoot}' '${volumeResourceRoot}/.spawnfile-volume-init'; chmod 0755 '${volumeResourceRoot}'; chmod 0600 '${volumeResourceRoot}/.spawnfile-volume-init'`]); + await execFile("docker", [ + "run", "--rm", "--entrypoint", "bash", + "--mount", `type=volume,source=${networkVolumeName},target=${networkRoot}`, + tag, "-ceu", `touch '${networkRoot}/moltnet.sqlite'; chown ${authorizedUid}:${authorizedUid} '${networkRoot}'; chmod 700 '${networkRoot}'` + ]); + await execFile("docker", [ + "create", "--name", containerName, + "--cap-drop=ALL", "--cap-add=CHOWN", "--cap-add=SETUID", "--cap-add=SETGID", "--cap-add=DAC_READ_SEARCH", + "--security-opt=no-new-privileges:true", + "--env", `${DAIMON_AUTHORIZED_UID_ENV}=${authorizedUid}`, + "--env", "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/untrusted", + "--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=${networkVolumeName},target=${networkRoot}`, + "--mount",`type=volume,source=${resourceVolumeName},target=${volumeResourceRoot},volume-nocopy`, + tag + ]); + const initial = await execFile("docker", ["start", "-a", containerName]); + const restarted = await execFile("docker", ["start", "-a", containerName]); + expect(initial.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=0`); + expect(restarted.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=1`); + await execFile("docker",["run","--rm","--entrypoint","bash","--mount",`type=volume,source=${resourceVolumeName},target=${volumeResourceRoot},volume-nocopy`,tag,"-ceu",`chown ${authorizedUid}:${authorizedUid} '${volumeResourceSentinel}'`]); + await expect(execFile("docker",["start","-a",containerName])).rejects.toThrow(/volume identity anchor is unsafe/u); + await execFile("docker", [ + "create", "--name", supervisorContainerName, "--user", `${authorizedUid}:${authorizedUid}`, + "--entrypoint", "/supervisor-entrypoint.sh", tag, + "--spawnfile-runtime-identity", `${authorizedUid}`, `${authorizedUid}` + ]); + const supervisorStarted = Date.now(); + await expect(execFile("docker", ["start", "-a", supervisorContainerName], { timeout: 5_000 })) + .rejects.toThrow(/Daimon exited before readiness/u); + expect(Date.now() - supervisorStarted).toBeLessThan(4_000); + await expect(execFile("docker", ["start", "-a", supervisorContainerName], { timeout: 5_000 })) + .rejects.toThrow(/Daimon exited before readiness/u); + await expect(execFile("docker", [ + "run", "--rm", "--user", "2001:2001", "--entrypoint", "bash", + "--mount", `type=volume,source=${networkVolumeName},target=${networkRoot},readonly`, + tag, "-ceu", + `! ls /var/lib/spawnfile >/dev/null 2>&1; ! cat '${instanceRoot}/daimon/config.json' >/dev/null 2>&1; ! cat '${receiptDirectory}/agent.json' >/dev/null 2>&1; ! cat /untrusted/sentinel >/dev/null 2>&1` + ])).resolves.toBeDefined(); + } finally { + vi.doUnmock("../runtime/index.js"); + vi.resetModules(); + await execFile("docker", ["rm", "--force", containerName]).catch(() => undefined); + await execFile("docker", ["rm", "--force", supervisorContainerName]).catch(() => undefined); + 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", networkVolumeName]).catch(() => undefined); + await execFile("docker",["volume","rm","--force",resourceVolumeName]).catch(()=>undefined); + await execFile("docker", ["image", "rm", "--force", tag]).catch(() => undefined); + await rm(dockerDirectory, { force: true, recursive: true }); + } + }, 60_000); + + it("rejects an ancestor symlink and ignores run-env roots before a restart can reach an external entrypoint", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-root-link-")); + const externalRoot = path.join(directory, "opt", "spawnfile", "root"); + const protectedEntrypoint = path.join(externalRoot, "entrypoint.sh"); + const hostileParent = path.join(directory, "compiled"); + const instanceRoot = path.join(hostileParent, "instance"); + const runEnvironment = path.join(directory, "run.env"); + const wrapper = path.join(directory, "daimon-uid-entrypoint.sh"); + try { + await mkdir(externalRoot, { recursive: true }); + await writeFile(protectedEntrypoint, "trusted root entrypoint\n", "utf8"); + await symlink(path.join(directory, "opt", "spawnfile", "root"), hostileParent); + await writeFile(runEnvironment, "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/opt/spawnfile/root\n", "utf8"); + const rendered = renderDaimonUidEntrypoint([{ + ...daimonPlan, + instancePaths: { + ...daimonPlan.instancePaths, + instanceRoot, + workspacePath: path.join(instanceRoot, "workspace") + } + }]); + await writeFile(wrapper, rendered, "utf8"); + for (const _restart of [0, 1]) { + const result = spawnSync("bash", ["-c", 'set -a; . "$1"; set +a; exec bash "$2"', "bash", runEnvironment, wrapper], { + env: process.env + }); + expect(result.status).not.toBe(0); + expect(Buffer.from(result.stderr).toString("utf8")).toContain("symbolic-link"); + } + expect(await readFile(protectedEntrypoint, "utf8")).toBe("trusted root entrypoint\n"); + expect((await lstat(externalRoot)).isDirectory()).toBe(true); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/compiler/containerDaimonUidEntrypointRender.test.ts b/src/compiler/containerDaimonUidEntrypointRender.test.ts index 2d469b68..78645c1a 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.test.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.test.ts @@ -1,21 +1,25 @@ import { describe, expect, it, vi } from "vitest"; import { execFile as execFileCallback, spawnSync } from "node:child_process"; -import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import type { EntrypointOptions } from "./containerEntrypointRender.js"; +import { renderEntrypoint } from "./containerEntrypointRender.js"; import { DAIMON_AUTHORIZED_UID_ENV, + DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS, + renderDaimonBrokerSocketWait, renderDaimonUidEntrypoint, + resolveDaimonVolumeIdentityFiles, resolveDaimonUidEntrypointOwnershipPlan, resolveDaimonUidEntrypointStateRoots } from "./containerDaimonUidEntrypointRender.js"; const execFile = promisify(execFileCallback); -const authorizedUid = 501; +const authorizedUid = 2000; const daimonPlan: RuntimeTargetPlan = { engineByNodeId: { "agent:AGY": "agy", "agent:Codex One": "codex", "agent:Grok Two": "grok" }, @@ -35,6 +39,50 @@ const nodeConfig = "/var/lib/spawnfile/moltnet/nodes/agent.json"; const causalState = "/var/lib/spawnfile/moltnet/servers/local/causal"; const agyRealm = "/var/lib/spawnfile/daimon/agy-subscription-realm"; const agyRuntimeHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy"; +const codexEngineHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/codex-one/.codex"; +const grokEngineHome = "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/grok-two/.grok"; + +describe("Daimon broker socket startup wait", () => { + const nodeSocketServer = `const net=require("node:net");const socket=process.argv[1];const delay=Number(process.argv[2]);setTimeout(()=>{const server=net.createServer();server.listen(socket,()=>setTimeout(()=>server.close(),500));},delay);`; + + it("uses the production cold-start budget and remains fail-fast and bounded", async () => { + expect(DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS).toBe(60); + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-broker-wait-")); + const waitProgram = renderDaimonBrokerSocketWait(2, 0.02).join("\n"); + const delayedSocket = path.join(root, "delayed.sock"); + await expect(execFile("bash", ["-ceu", `${waitProgram}\nnode -e "$0" "$1" 120 & child=$!\nwait_for_broker_socket "$1" "$child" delayed\nkill "$child" 2>/dev/null || true\nwait "$child" 2>/dev/null || true`, nodeSocketServer, delayedSocket])).resolves.toBeDefined(); + + const earlyStarted = Date.now(); + await expect(execFile("bash", ["-ceu", `${waitProgram}\nnode -e 'process.exit(7)' & child=$!\nwait_for_broker_socket "$1" "$child" early`, "", path.join(root, "early.sock")])).rejects.toThrow(/early exited before readiness \(status 7\)/u); + expect(Date.now() - earlyStarted).toBeLessThan(1_000); + + const timeoutStarted = Date.now(); + await expect(execFile("bash", ["-ceu", `${renderDaimonBrokerSocketWait(1, 0.02).join("\n")}\nsleep 5 & child=$!\ntrap 'kill "$child" 2>/dev/null || true' EXIT\nwait_for_broker_socket "$1" "$child" never`, "", path.join(root, "never.sock")])).rejects.toThrow(/never readiness timed out after 1s/u); + expect(Date.now() - timeoutStarted).toBeLessThan(2_500); + await rm(root, { recursive: true, force: true }); + }); + + it("waits for the exact post-drop process identity within the shared budget", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-broker-identity-")); + const waitProgram = renderDaimonBrokerSocketWait(2, 0.02).join("\n"); + const delayed = `${waitProgram}\nbroker_process_status_root="$1"\nsleep 5 & child=$!\ntrap 'kill "$child" 2>/dev/null || true' EXIT\nmkdir -p "$1/$child"\nprintf 'Uid:\\t0\\nCapBnd:\\t00000000000000c1\\n' > "$1/$child/status"\n(sleep 0.12; printf 'Uid:\\t2100\\nCapBnd:\\t0000000000000000\\n' > "$1/$child/status") &\nwait_for_broker_identity "$child" 2100 0000000000000000 relay`; + await expect(execFile("bash", ["-ceu", delayed, "", root])).resolves.toBeDefined(); + + const never = `${renderDaimonBrokerSocketWait(1, 0.02).join("\n")}\nbroker_process_status_root="$1"\nsleep 5 & child=$!\ntrap 'kill "$child" 2>/dev/null || true' EXIT\nmkdir -p "$1/$child"\nprintf 'Uid:\\t0\\nCapBnd:\\t00000000000000c1\\n' > "$1/$child/status"\nwait_for_broker_identity "$child" 2100 0000000000000000 relay`; + await expect(execFile("bash", ["-ceu", never, "", root])).rejects.toThrow(/relay identity readiness timed out after 1s/u); + + const exited = `${waitProgram}\nbroker_process_status_root="$1"\nnode -e 'process.exit(9)' & child=$!\nwait_for_broker_identity "$child" 2100 0000000000000000 relay`; + await expect(execFile("bash", ["-ceu", exited, "", root])).rejects.toThrow(/relay exited before identity readiness \(status 9\)/u); + await rm(root, { recursive: true, force: true }); + }); +}); +const acceptanceStore = "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance"; +const acceptanceStoreMount = { + id: "daimon-organization-acceptance-store", + mount_path: acceptanceStore, + reason: "Daimon organization durable wake acceptance store", + volume_name: "spawnfile-test-daimon-acceptance-store" +}; const agyRealmMount = { id: "daimon-agy-subscription-realm", mount_path: agyRealm, @@ -47,6 +95,18 @@ const agyRuntimeHomeMount = { reason: "Daimon AGY subscription runtime home for agent:agy", volume_name: "spawnfile-test-agy-runtime-home" }; +const codexEngineHomeMount = { + id: "daimon-engine-home-codex-codex-one", + mount_path: codexEngineHome, + reason: "Daimon codex subscription credential home for agent:Codex One", + volume_name: "spawnfile-test-codex-engine-home" +}; +const grokEngineHomeMount = { + id: "daimon-engine-home-grok-grok-two", + mount_path: grokEngineHome, + reason: "Daimon grok subscription credential home for agent:Grok Two", + volume_name: "spawnfile-test-grok-engine-home" +}; const moltnetPlans = { nodePlans: [{ configPath: nodeConfig, networkId: "local" }], serverPlans: [{ @@ -70,27 +130,132 @@ const moltnetPlans = { } satisfies NonNullable; describe("renderDaimonUidEntrypoint", () => { + it("deduplicates shared declared volume anchors and rejects conflicting or escaping identity metadata",()=>{const path="/var/lib/spawnfile/resources/teams/example/shared/.spawnfile-resource-identity",base={...daimonPlan,resources:[{id:"shared",kind:"volume",linkPath:"/workspace/shared",backingPath:"/var/lib/spawnfile/resources/teams/example/shared",mount:"./shared",mode:"mutable",sharing:"team",replacementSentinel:path,resolvedIdentity:`sha256:${"a".repeat(64)}`} as NonNullable[number]&{replacementSentinel:string;resolvedIdentity:string}]};expect(resolveDaimonVolumeIdentityFiles([base,base])).toEqual([{path,identity:`sha256:${"a".repeat(64)}`}]);const conflict={...base,resources:[{...base.resources[0]!,resolvedIdentity:`sha256:${"b".repeat(64)}`} ]};expect(()=>resolveDaimonVolumeIdentityFiles([base,conflict])).toThrow(/conflicting compiler-authored volume identity anchor/u);const invalidRoots=["/var/lib/spawnfile/resources/../../etc","/var/lib/spawnfile/resources","/var/lib/spawnfile/resources/"];for(const backingPath of invalidRoots){const invalid={...base,resources:[{...base.resources[0]!,backingPath,replacementSentinel:`${backingPath}/.spawnfile-resource-identity`} ]};expect(()=>resolveDaimonVolumeIdentityFiles([invalid])).toThrow(/invalid compiler-authored volume identity anchor/u);}}); + it("secures the durable organization acceptance store for the authorized runtime UID", () => { + const ownership = resolveDaimonUidEntrypointOwnershipPlan( + [{ ...daimonPlan, persistentMounts: [acceptanceStoreMount] }], + [acceptanceStore] + ); + + expect(ownership.privateModeDirectories).toContain(acceptanceStore); + expect(ownership.stateRoots).toContain(acceptanceStore); + }); + + it("secures compiler-authored Daimon receipt follower directories", () => { + const receiptPath = "/var/lib/spawnfile/moltnet/networks/local/daimon-receipts/relay-agent.json"; + const ownership = resolveDaimonUidEntrypointOwnershipPlan( + [daimonPlan], + ["/var/lib/spawnfile/moltnet/networks/local"], + { ...moltnetPlans, nodePlans: [{ ...moltnetPlans.nodePlans[0]!, receiptStorePath: receiptPath }] } + ); + + expect(ownership.privateModeDirectories).toContain(path.posix.dirname(receiptPath)); + expect(ownership.privateDirectories).toContain(path.posix.dirname(receiptPath)); + expect(ownership.creatablePrivateDirectories).toEqual([ + { anchor: "/var/lib/spawnfile/moltnet/networks/local", target: path.posix.dirname(receiptPath) }, + { anchor: "/run", target: "/run/spawnfile/moltnet-readiness" } + ]); + expect(ownership.stateRoots).toContain("/var/lib/spawnfile/moltnet/networks/local"); + }); + it("reowns only compiler-authored state, skips opaque mounts, and drops every capability before the existing entrypoint", () => { + const volumeRoot="/var/lib/spawnfile/resources/teams/example/shared",volumeSentinel=`${volumeRoot}/.spawnfile-resource-identity`,volumeIdentity="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const rendered = renderDaimonUidEntrypoint( - [{ ...daimonPlan, persistentMounts: [agyRealmMount, agyRuntimeHomeMount] }], - [agyRealm, agyRuntimeHome, "/var/lib/spawnfile/daimon-state"], + [{ ...daimonPlan, persistentMounts: [ + agyRealmMount, agyRuntimeHomeMount, codexEngineHomeMount, grokEngineHomeMount + ],resources:[{id:"shared",kind:"volume",linkPath:"/var/lib/spawnfile/instances/daimon/daimon-organization/workspace/shared",backingPath:volumeRoot,mount:"./shared",mode:"mutable",sharing:"team",replacementSentinel:volumeSentinel,resolvedIdentity:volumeIdentity} as NonNullable[number]&{replacementSentinel:string;resolvedIdentity:string}] }], + [agyRealm, agyRuntimeHome, codexEngineHome, grokEngineHome, volumeRoot,"/var/lib/spawnfile/daimon-state"], moltnetPlans ); - expect(rendered).toContain(`uid="\${${DAIMON_AUTHORIZED_UID_ENV}:-1001}"`); + expect(rendered).toContain("uid=2000"); + expect(rendered).toContain("for fixed_uid in 2000 2100 2200"); + expect(rendered).toContain("Buffer.alloc(692)"); + expect(rendered).toContain("/etc/daimon-engine-broker/registrations.bin"); + expect(rendered).toContain("http://127.0.0.1:43123/v1"); + 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("sandbox-events.jsonl"); + expect(rendered).toContain("restrict_network = true"); + expect(rendered).toContain("fs.chmodSync(target, 0o750)"); + expect(rendered).toContain("fs.chmodSync(target, 0o640)"); + expect(rendered).toContain("unsafe worker workspace link"); + expect(rendered).toContain("resourceByLink.get(target)"); + expect(rendered).toContain("raw !== resource.backingPath"); + expect(rendered).toContain('!raw.startsWith("/var/lib/spawnfile/resources/")'); + expect(rendered).toContain("volumeIdentityPaths.has(childPath)"); + expect(rendered).toContain(JSON.stringify({path:volumeSentinel,identity:volumeIdentity})); + expect(rendered).toContain("before.nlink !== 1"); + expect(rendered).toContain("constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK"); + 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("ensureEventsFile(eventsPath, entry.uid)"); + expect(rendered).toContain("noopolis.daimon.engine-broker-service.v1"); + expect(rendered).toContain("/etc/daimon-engine-broker/service.json"); + expect(rendered).toContain("readSecure(bootstrap, undefined, 'bootstrap')"); + expect(rendered).toContain("noopolis.daimon.broker-credential-journal.v1"); + expect(rendered).toContain("journal.state === 'stale'"); + expect(rendered).toContain("bootstrapDigest === journal.sourceDigest"); + 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("--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"'); + expect(rendered).toContain('expected_caps=${rest##*:}'); + expect(rendered).toContain("--reuid 2100 --regid 2100"); + expect(rendered).toContain("engine-broker serve &"); + expect(rendered).toContain("broker_startup_timeout_seconds=60"); + expect(rendered).toContain("wait_for_broker_socket '/run/daimon-engine-broker/backend.sock'"); + expect(rendered).toContain('wait_for_broker_identity "$relay_pid" 2100 0000000000000000'); + expect(rendered).toContain("broker_startup_started=$SECONDS"); + expect(rendered).toContain("startup_children+=(\"$broker_pid\")"); + expect(rendered).toContain("trap cleanup_broker_startup EXIT"); + expect(rendered).not.toContain("seq 1 100"); + expect(rendered).toContain("/run/daimon-engine-broker/backend.sock"); + expect(rendered).toContain("/run/daimon-engine-broker/launcher.sock"); + expect(rendered).toContain('wait -n -p finished_pid "${watch_pids[@]}"'); + expect(rendered).toContain("finished_pid="); + expect(rendered).toContain('${finished_pid:-}'); + expect(rendered).toContain("[ -S '/run/daimon-engine-broker/control.sock' ]"); + expect(rendered).not.toMatch(/0\.0\.0\.0:4312[34]/u); expect(rendered).toContain("runtime-homes/codex-one/.daimon-inbound/codex-auth"); - expect(rendered).toContain("runtime-homes/grok-two/.daimon-inbound/grok-auth"); + expect(rendered).not.toContain("runtime-homes/grok-two/.daimon-inbound/grok-auth"); expect(rendered).toContain("/var/lib/spawnfile/daimon/agy-unlock-secret"); expect(rendered).not.toContain("runtime-homes/agy/.daimon-inbound/agy-auth"); expect(rendered).toContain("const opaquePaths = new Set("); + expect(rendered).toContain( + `const opaqueDescendantRoots = new Set(["${codexEngineHome}","${grokEngineHome}"]);` + ); + expect(rendered).toContain( + "opaquePaths.has(childPath) || opaqueDescendantRoots.has(childPath)" + ); expect(rendered).toContain("constants.O_NOFOLLOW"); expect(rendered).toContain("fs.fchownSync"); - expect(rendered).toContain(`const privateFiles = ["${nodeConfig}","${serverConfig}"];`); - expect(rendered).toContain(`const privateModeDirectories = ["${agyRealm}","${agyRuntimeHome}"];`); + expect(rendered).toContain(`const privateFiles = ["${daimonPlan.instancePaths.configPath}","${nodeConfig}","${serverConfig}"];`); + expect(rendered).toContain( + `const privateModeDirectories = ["${agyRealm}","${daimonPlan.instancePaths.instanceRoot}/daimon","${agyRuntimeHome}","${codexEngineHome}","${grokEngineHome}"];` + ); expect(rendered).toContain("for (const target of privateDirectories)"); expect(rendered).toContain("for (const target of privateModeDirectories)"); expect(rendered).toContain("for (const target of privateFiles)"); expect(rendered).toContain("const securePrivateDirectory = (fd) => {"); + expect(rendered).toContain("for (const target of ['/var', '/var/lib']) secureFixedTraversalAncestor(target)"); + expect(rendered).toContain("secureSharedStateAncestor('/var/lib/spawnfile')"); + expect(rendered).toContain("info.uid === 0 && info.gid === 0 && mode === 0o711"); + expect(rendered).toContain("info.uid === uid && info.gid === uid && mode === 0o700"); + expect(rendered).toContain("mode !== 0o775 && mode !== 0o755 && mode !== 0o711"); + expect(rendered).toContain("shared state ancestor has an unexpected preimage"); + expect(rendered).toContain("probe=/var/lib/spawnfile/daimon/grok-subscription-realm/.daimon-ancestry-probe"); + expect(rendered).toContain("--reuid 2000 --regid 2000"); + expect(rendered).toContain("--reuid 2200 --regid 2200"); + expect(rendered).toContain("info.uid !== 0 || info.gid !== 0"); expect(rendered).toContain("fs.fchownSync(fd, 0, 0);"); expect(rendered).toContain("fs.fchmodSync(fd, 0o700)"); expect(rendered).toContain("fs.fchownSync(fd, uid, uid);"); @@ -124,17 +289,23 @@ describe("renderDaimonUidEntrypoint", () => { it("repairs only exact private traversal ancestors, Moltnet configs, and writable leaves", () => { expect(resolveDaimonUidEntrypointOwnershipPlan( - [{ ...daimonPlan, persistentMounts: [agyRealmMount, agyRuntimeHomeMount] }], - [agyRealm, agyRuntimeHome, causalState, "/external-state"], + [{ ...daimonPlan, persistentMounts: [ + agyRealmMount, agyRuntimeHomeMount, codexEngineHomeMount, grokEngineHomeMount + ] }], + [agyRealm, agyRuntimeHome, codexEngineHome, grokEngineHome, causalState, "/external-state"], moltnetPlans )).toEqual({ + creatablePrivateDirectories: [ + { anchor: "/run", target: "/run/spawnfile/moltnet-readiness" } + ], + opaqueDescendantRoots: [codexEngineHome, grokEngineHome], privateDirectories: [ - "/var/lib/spawnfile", "/var/lib/spawnfile/daimon", agyRealm, "/var/lib/spawnfile/instances", "/var/lib/spawnfile/instances/daimon", "/var/lib/spawnfile/instances/daimon/daimon-organization", + "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon", "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes", agyRuntimeHome, "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace", @@ -144,8 +315,14 @@ describe("renderDaimonUidEntrypoint", () => { "/var/lib/spawnfile/moltnet/servers/local", causalState ], - privateFiles: [nodeConfig, serverConfig], - privateModeDirectories: [agyRealm, agyRuntimeHome], + privateFiles: [daimonPlan.instancePaths.configPath, nodeConfig, serverConfig], + privateModeDirectories: [ + agyRealm, + "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon", + agyRuntimeHome, + codexEngineHome, + grokEngineHome + ], stateRoots: [ "/external-state", agyRealm, @@ -169,6 +346,7 @@ describe("renderDaimonUidEntrypoint", () => { }]); expect(rendered).toContain("const opaquePaths = new Set([]);"); + expect(rendered).toContain("const opaqueDescendantRoots = new Set([]);"); }); it("uses only absolute roots when plan metadata or persistent input is incomplete", () => { @@ -185,170 +363,4 @@ describe("renderDaimonUidEntrypoint", () => { expect(rendered).not.toContain("runtime-homes/codex-one/.daimon-inbound/codex-auth"); }); - it("repairs a fresh volume and private Moltnet ancestors for authorized UID 501 across restart", async () => { - const instanceRoot = "/var/lib/spawnfile/instances/daimon/daimon-organization"; - const workspacePath = `${instanceRoot}/workspace`; - const runtimeHomesPath = `${instanceRoot}/runtime-homes`; - const dockerDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-uid-image-")); - const tag = `spawnfile-daimon-uid-${Date.now().toString(36)}`; - const containerName = `${tag}-container`; - const volumeName = `${tag}-realm-volume`; - const runtimeHomeVolumeName = `${tag}-agy-runtime-home-volume`; - const plan: RuntimeTargetPlan = { - ...daimonPlan, - engineByNodeId: undefined, - instancePaths: { - configPath: `${instanceRoot}/daimon/config.json`, - instanceRoot, - workspacePath - }, - meta: { - ...daimonPlan.meta, - configFileName: "daimon/config.json", - startCommand: ["true"], - systemDeps: ["bash", "dbus-daemon", "util-linux"] - }, - persistentMounts: [ - { ...agyRealmMount, volume_name: volumeName }, - { ...agyRuntimeHomeMount, volume_name: runtimeHomeVolumeName } - ], - targetFiles: [{ content: "{}\n", path: "daimon/config.json" }] - }; - try { - vi.resetModules(); - vi.doMock("../runtime/index.js", () => ({ - createRuntimeInstallRecipe: vi.fn(async () => ({ - baseImage: "node:24-bookworm-slim", - commands: [], - copyCommands: [], - runtimeName: "daimon", - runtimeRoot: "/opt/daimon" - })) - })); - const { createRootfsFiles, renderDockerfile } = await import("./containerArtifactsRender.js"); - const dockerfile = await renderDockerfile([plan], { - moltnet: moltnetPlans, - persistentMountPaths: [agyRealm, agyRuntimeHome, causalState] - }); - const stateRoots = resolveDaimonUidEntrypointStateRoots([plan]); - expect(stateRoots).toEqual([runtimeHomesPath, workspacePath]); - for (const stateRoot of stateRoots) { - expect(dockerfile).toContain(`install -d -o root -g root -m 700 '${stateRoot}'`); - } - expect(dockerfile).not.toContain("SPAWNFILE_DAIMON_WRITABLE_ROOTS"); - expect(dockerfile).not.toContain("/untrusted"); - - for (const file of createRootfsFiles([plan], [agyRealm, agyRuntimeHome, causalState], moltnetPlans)) { - const outputPath = path.join(dockerDirectory, file.path); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, file.content, "utf8"); - } - for (const configPath of [nodeConfig, serverConfig]) { - const outputPath = path.join(dockerDirectory, "container/rootfs", configPath); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, "{}\n", { encoding: "utf8", mode: 0o600 }); - } - await writeFile(path.join(dockerDirectory, ".env.example"), "", "utf8"); - await writeFile( - path.join(dockerDirectory, "entrypoint.sh"), - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `test \"$(id -u)\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}\"`, - "getent passwd \"$(id -u)\" >/dev/null", - "test \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" = 0000000000000000", - `test \"$(stat -c '%u:%a' '/var/lib/spawnfile')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '/var/lib/spawnfile/moltnet/servers/local')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '${serverConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, - `test \"$(stat -c '%u:%a' '${nodeConfig}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:600\"`, - `test \"$(stat -c '%u:%a' '${causalState}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '${agyRealm}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '${runtimeHomesPath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test \"$(stat -c '%u:%a' '${workspacePath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, - `test "$(stat -c '%u:%a' '${agyRuntimeHome}')" = "\${${DAIMON_AUTHORIZED_UID_ENV}}:700"`, - "test \"$(stat -c '%u:%a' /untrusted/sentinel)\" = 0:600", - "dbus_root=$(mktemp -d /tmp/spawnfile-dbus.XXXXXX)", - "chmod 700 \"$dbus_root\"", - "dbus_address=unix:path=$dbus_root/bus", - "dbus-daemon --session --fork --nopidfile --address=\"$dbus_address\"", - "dbus-send --bus=\"$dbus_address\" --dest=org.freedesktop.DBus --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null", - `count_file='${agyRealm}/starts'`, - "count=$(cat \"$count_file\" 2>/dev/null || printf 0)", - "printf %s $((count + 1)) > \"$count_file\"", - `token_marker='${agyRuntimeHome}/subscription-state'`, - "if [ \"$count\" = 0 ]; then printf enrolled > \"$token_marker\"; else test \"$(cat \"$token_marker\")\" = enrolled; fi", - `printf 'entrypoint uid=%s caps=%s realm=%s start=%s\\n' \"$(id -u)\" \"$(sed -n 's/^CapEff:[[:space:]]*//p' /proc/self/status)\" \"$(stat -c '%u:%a' '${agyRealm}')\" \"$count\"` - ].join("\n") + "\n", - "utf8" - ); - await writeFile( - path.join(dockerDirectory, "Dockerfile"), - `${dockerfile}\nRUN install -d -o root -g root -m 755 /untrusted && install -o root -g root -m 600 /dev/null /untrusted/sentinel\n`, - "utf8" - ); - await execFile("docker", ["build", "--pull=false", "--tag", tag, "."], { - cwd: dockerDirectory, - timeout: 30_000 - }); - await execFile("docker", [ - "create", "--name", containerName, - "--cap-drop=ALL", "--cap-add=CHOWN", "--cap-add=SETUID", "--cap-add=SETGID", "--cap-add=DAC_READ_SEARCH", - "--security-opt=no-new-privileges:true", - "--env", `${DAIMON_AUTHORIZED_UID_ENV}=${authorizedUid}`, - "--env", "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/untrusted", - "--mount", `type=volume,source=${volumeName},target=${agyRealm}`, - "--mount", `type=volume,source=${runtimeHomeVolumeName},target=${agyRuntimeHome}`, - tag - ]); - const initial = await execFile("docker", ["start", "-a", containerName]); - const restarted = await execFile("docker", ["start", "-a", containerName]); - expect(initial.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=0`); - expect(restarted.stdout).toContain(`entrypoint uid=${authorizedUid} caps=0000000000000000 realm=${authorizedUid}:700 start=1`); - } finally { - vi.doUnmock("../runtime/index.js"); - vi.resetModules(); - await execFile("docker", ["rm", "--force", containerName]).catch(() => undefined); - await execFile("docker", ["volume", "rm", "--force", volumeName]).catch(() => undefined); - await execFile("docker", ["volume", "rm", "--force", runtimeHomeVolumeName]).catch(() => undefined); - await execFile("docker", ["image", "rm", "--force", tag]).catch(() => undefined); - await rm(dockerDirectory, { force: true, recursive: true }); - } - }, 60_000); - - it("rejects an ancestor symlink and ignores run-env roots before a restart can reach an external entrypoint", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-root-link-")); - const externalRoot = path.join(directory, "opt", "spawnfile", "root"); - const protectedEntrypoint = path.join(externalRoot, "entrypoint.sh"); - const hostileParent = path.join(directory, "compiled"); - const instanceRoot = path.join(hostileParent, "instance"); - const runEnvironment = path.join(directory, "run.env"); - const wrapper = path.join(directory, "daimon-uid-entrypoint.sh"); - try { - await mkdir(externalRoot, { recursive: true }); - await writeFile(protectedEntrypoint, "trusted root entrypoint\n", "utf8"); - await symlink(path.join(directory, "opt", "spawnfile", "root"), hostileParent); - await writeFile(runEnvironment, "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/opt/spawnfile/root\n", "utf8"); - const rendered = renderDaimonUidEntrypoint([{ - ...daimonPlan, - instancePaths: { - ...daimonPlan.instancePaths, - instanceRoot, - workspacePath: path.join(instanceRoot, "workspace") - } - }]); - await writeFile(wrapper, rendered, "utf8"); - for (const _restart of [0, 1]) { - const result = spawnSync("bash", ["-c", 'set -a; . "$1"; set +a; exec bash "$2"', "bash", runEnvironment, wrapper], { - env: process.env - }); - expect(result.status).not.toBe(0); - expect(Buffer.from(result.stderr).toString("utf8")).toContain("symbolic-link"); - } - expect(await readFile(protectedEntrypoint, "utf8")).toBe("trusted root entrypoint\n"); - expect((await lstat(externalRoot)).isDirectory()).toBe(true); - } finally { - await rm(directory, { force: true, recursive: true }); - } - }); }); diff --git a/src/compiler/containerDaimonUidEntrypointRender.ts b/src/compiler/containerDaimonUidEntrypointRender.ts index fcae8e9e..adcef2fd 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.ts @@ -2,15 +2,65 @@ 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 { MOLTNET_READINESS_DIRECTORY } from "./containerReadinessPaths.js"; +import { + renderDaimonOwnershipProgram, + resolveDaimonVolumeIdentityFiles +} from "./containerDaimonOwnershipGuardRender.js"; +export { resolveDaimonVolumeIdentityFiles } from "./containerDaimonOwnershipGuardRender.js"; +import { + DAIMON_BROKER_EXECUTABLE, + DAIMON_BROKER_BACKEND_SOCKET, + DAIMON_BROKER_LAUNCHER_SOCKET, + DAIMON_BROKER_REALM, + DAIMON_BROKER_SOCKET, + DAIMON_BROKER_UID, + DAIMON_ORGANIZATION_UID, + renderDaimonBrokerProvisioning, + resolveDaimonGrokRegistrations +} from "./containerDaimonBrokerRender.js"; export const DAIMON_AUTHORIZED_UID_ENV = "SPAWNFILE_DAIMON_AUTHORIZED_UID"; -export const DAIMON_RUNTIME_UID = 1001; +export const DAIMON_RUNTIME_UID = DAIMON_ORGANIZATION_UID; export const DAIMON_UID_ENTRYPOINT_PATH = "/opt/spawnfile/daimon-uid-entrypoint.sh"; export const DAIMON_RUNTIME_HOMES_DIRECTORY = "runtime-homes"; +export const DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS = 60; + +export const renderDaimonBrokerSocketWait = ( + timeoutSeconds = DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS, + pollSeconds = 0.1 +): string[] => [ + `broker_startup_timeout_seconds=${timeoutSeconds}`, + `broker_startup_poll_seconds=${pollSeconds}`, + "broker_startup_started=$SECONDS", + "broker_process_status_root=/proc", + "wait_for_broker_socket() {", + " socket=$1; child=$2; label=$3", + " while [ ! -S \"$socket\" ]; do", + " if ! kill -0 \"$child\" 2>/dev/null; then set +e; wait \"$child\"; child_status=$?; set -e; echo \"$label exited before readiness (status $child_status)\" >&2; return 1; fi", + " if [ $((SECONDS - broker_startup_started)) -ge \"$broker_startup_timeout_seconds\" ]; then echo \"$label readiness timed out after ${broker_startup_timeout_seconds}s\" >&2; return 1; fi", + " sleep \"$broker_startup_poll_seconds\"", + " done", + "}", + "wait_for_broker_identity() {", + " child=$1; expected_uid=$2; expected_caps=$3; label=$4", + " while kill -0 \"$child\" 2>/dev/null; do", + " status_path=$broker_process_status_root/$child/status", + " observed_uid=$(awk '/^Uid:/{print $2}' \"$status_path\" 2>/dev/null || true)", + " observed_caps=$(awk '/^CapBnd:/{print $2}' \"$status_path\" 2>/dev/null || true)", + " if [ \"$observed_uid\" = \"$expected_uid\" ] && [ \"$observed_caps\" = \"$expected_caps\" ]; then return 0; fi", + " if [ $((SECONDS - broker_startup_started)) -ge \"$broker_startup_timeout_seconds\" ]; then echo \"$label identity readiness timed out after ${broker_startup_timeout_seconds}s\" >&2; return 1; fi", + " sleep \"$broker_startup_poll_seconds\"", + " done", + " set +e; wait \"$child\"; child_status=$?; set -e; echo \"$label exited before identity readiness (status $child_status)\" >&2; return 1", + "}" +]; const SPAWNFILE_PRIVATE_STATE_ROOT = "/var/lib/spawnfile"; 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-"; const quote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`; @@ -25,8 +75,9 @@ const opaqueMountTargets = (runtimePlans: RuntimeTargetPlan[]): string[] => .filter((plan) => plan.runtimeName === "daimon") .flatMap((plan) => [ ...(plan.opaqueMountTargets ?? []), + ...(plan.resources ?? []).map((resource) => resource.linkPath), ...Object.entries(plan.engineByNodeId ?? {}) - .filter(([, engine]) => engine === "codex" || engine === "grok") + .filter(([, engine]) => engine === "codex") .map(([nodeId, engine]) => path.posix.join( plan.instancePaths.instanceRoot ?? "", DAIMON_RUNTIME_HOMES_DIRECTORY, nodeSlug(nodeId), ".daimon-inbound", `${engine}-auth` @@ -57,7 +108,7 @@ const writableStateRoots = ( ...resolveDaimonUidEntrypointStateRoots(runtimePlans), ...persistentMountPaths ]) -].filter((root) => root.startsWith("/")).sort(); +].filter((root) => root.startsWith("/") && root !== DAIMON_BROKER_REALM).sort(); const privateDirectoriesThrough = (target: string): string[] => { if ( @@ -74,12 +125,28 @@ const privateDirectoriesThrough = (target: string): string[] => { ]; }; -const privateModeDirectories = (runtimePlans: RuntimeTargetPlan[]): string[] => [ +const privateModeDirectories = (runtimePlans: RuntimeTargetPlan[], moltnet?: EntrypointOptions["moltnet"]): string[] => [ ...new Set(runtimePlans .filter((plan) => plan.runtimeName === "daimon") .flatMap((plan) => plan.persistentMounts ?? []) - .filter((mount) => mount.id === DAIMON_AGY_SUBSCRIPTION_REALM_MOUNT_ID - || mount.id.startsWith(DAIMON_AGY_RUNTIME_HOME_MOUNT_ID_PREFIX)) + .filter((mount) => mount.id === DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID + || mount.id === DAIMON_AGY_SUBSCRIPTION_REALM_MOUNT_ID + || mount.id.startsWith(DAIMON_AGY_RUNTIME_HOME_MOUNT_ID_PREFIX) + || mount.id.startsWith(DAIMON_PORTABLE_ENGINE_HOME_MOUNT_ID_PREFIX)) + .map((mount) => mount.mount_path) + .filter((target) => target.startsWith("/")) + .concat((moltnet?.nodePlans ?? []).flatMap((plan) => + plan.receiptStorePath ? [path.posix.dirname(plan.receiptStorePath)] : [] + ))) +].sort(); + +const portableEngineHomeDirectories = ( + runtimePlans: RuntimeTargetPlan[] +): string[] => [ + ...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .flatMap((plan) => plan.persistentMounts ?? []) + .filter((mount) => mount.id.startsWith(DAIMON_PORTABLE_ENGINE_HOME_MOUNT_ID_PREFIX)) .map((mount) => mount.mount_path) .filter((target) => target.startsWith("/"))) ].sort(); @@ -96,7 +163,21 @@ const moltnetConfigPaths = ( ].filter((configPath) => configPath.startsWith("/")).sort(); }; +const daimonConfigPaths = (runtimePlans: RuntimeTargetPlan[]): string[] => [ + ...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .map((plan) => plan.instancePaths.configPath) + .filter((configPath) => configPath.startsWith("/"))) +].sort(); + +const moltnetReceiptDirectories = (moltnet: EntrypointOptions["moltnet"]): string[] => + [...new Set((moltnet?.nodePlans ?? []).flatMap((plan) => + plan.receiptStorePath ? [path.posix.dirname(plan.receiptStorePath)] : [] + ))].filter((directory) => directory.startsWith("/")).sort(); + export interface DaimonUidEntrypointOwnershipPlan { + creatablePrivateDirectories: Array<{ anchor: string; target: string }>; + opaqueDescendantRoots: string[]; privateDirectories: string[]; privateFiles: string[]; privateModeDirectories: string[]; @@ -108,18 +189,43 @@ export const resolveDaimonUidEntrypointOwnershipPlan = ( persistentMountPaths: string[] = [], moltnet?: EntrypointOptions["moltnet"] ): DaimonUidEntrypointOwnershipPlan => { - const stateRoots = writableStateRoots(runtimePlans, persistentMountPaths); - const privateFiles = moltnetConfigPaths(moltnet); - const modeDirectories = privateModeDirectories(runtimePlans); + const opaqueDescendantRoots = portableEngineHomeDirectories(runtimePlans); + const stateRoots = writableStateRoots(runtimePlans, persistentMountPaths) + .filter((root) => !opaqueDescendantRoots.some((opaqueRoot) => + root === opaqueRoot || root.startsWith(`${opaqueRoot}/`) + )); + const privateFiles = [ + ...new Set([...daimonConfigPaths(runtimePlans), ...moltnetConfigPaths(moltnet)]) + ].sort(); + const modeDirectories = [ + ...new Set([ + ...privateModeDirectories(runtimePlans, moltnet), + ...daimonConfigPaths(runtimePlans).map((configPath) => path.posix.dirname(configPath)) + ]) + ].sort(); + const receiptDirectories = moltnetReceiptDirectories(moltnet); + const creatableTargets = [ + ...receiptDirectories, + ...((moltnet?.nodePlans.length ?? 0) > 0 ? [MOLTNET_READINESS_DIRECTORY] : []) + ]; const privateDirectories = [ ...new Set([ ...stateRoots.flatMap(privateDirectoriesThrough), + ...moltnetReceiptDirectories(moltnet).flatMap(privateDirectoriesThrough), ...privateFiles.flatMap((configPath) => privateDirectoriesThrough(path.posix.dirname(configPath)) ) ]) - ].sort(); + ].filter((directory) => directory !== SPAWNFILE_PRIVATE_STATE_ROOT).sort(); return { + creatablePrivateDirectories: creatableTargets.map((target) => ({ + anchor: target === MOLTNET_READINESS_DIRECTORY + ? "/run" + : stateRoots.filter((root) => target === root || target.startsWith(`${root}/`)) + .sort((left, right) => right.length - left.length)[0] ?? SPAWNFILE_PRIVATE_STATE_ROOT, + target + })), + opaqueDescendantRoots, privateDirectories, privateFiles, privateModeDirectories: modeDirectories, @@ -127,89 +233,6 @@ export const resolveDaimonUidEntrypointOwnershipPlan = ( }; }; -const renderOwnershipProgram = ( - opaqueTargets: string[], - privateDirectories: string[], - privateFiles: string[], - privateModeDirectories: string[] -): string => [ - "const fs = require('node:fs');", - "const constants = fs.constants;", - "const uid = Number(process.argv[2]);", - "const roots = process.argv.slice(3);", - `const opaquePaths = new Set(${JSON.stringify(opaqueTargets)});`, - `const privateDirectories = ${JSON.stringify(privateDirectories)};`, - `const privateFiles = ${JSON.stringify(privateFiles)};`, - `const privateModeDirectories = ${JSON.stringify(privateModeDirectories)};`, - "const fail = (message) => { process.stderr.write(`Daimon ownership guard: ${message}\\n`); process.exit(1); };", - "if (!Number.isSafeInteger(uid) || uid < 1 || roots.length === 0) fail('invalid compiler-authored roots');", - "const decodeMountPath = (value) => value.replace(/\\\\([0-7]{3})/g, (_, octal) => String.fromCharCode(Number.parseInt(octal, 8)));", - "const mountOptionsFor = (target) => {", - " const matches = fs.readFileSync('/proc/self/mountinfo', 'utf8').trim().split('\\n').map((line) => line.split(' ')).filter((parts) => parts.length > 5).map((parts) => ({ point: decodeMountPath(parts[4]), options: parts[5].split(',') })).filter((mount) => target === mount.point || target.startsWith(`${mount.point}/`));", - " return matches.sort((left, right) => right.point.length - left.point.length)[0]?.options ?? [];", - "};", - "const openDirectoryPath = (target) => {", - " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored root');", - " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", - " try {", - " for (const segment of target.slice(1).split('/')) {", - " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored root');", - " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", - " fs.closeSync(fd); fd = next;", - " }", - " const info = fs.fstatSync(fd);", - " if (!info.isDirectory()) fail('root is not a directory');", - " if (mountOptionsFor(target).includes('ro')) fail('root is read-only');", - " return fd;", - " } catch (error) { try { fs.closeSync(fd); } catch {} fail('root has a symbolic-link or unavailable path component'); }", - "};", - "const openRegularFilePath = (target) => {", - " if (!target.startsWith('/') || target === '/' || target.includes('//') || target.split('/').includes('..')) fail('unsafe compiler-authored file');", - " let fd = fs.openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);", - " try {", - " const segments = target.slice(1).split('/');", - " for (const [index, segment] of segments.entries()) {", - " if (!segment || segment === '.' || segment === '..') fail('unsafe compiler-authored file');", - " const isFile = index === segments.length - 1;", - " const next = fs.openSync(`/proc/self/fd/${fd}/${segment}`, constants.O_RDONLY | constants.O_NOFOLLOW | (isFile ? 0 : constants.O_DIRECTORY));", - " fs.closeSync(fd); fd = next;", - " }", - " const info = fs.fstatSync(fd);", - " if (!info.isFile() || info.nlink !== 1) fail('compiler-authored file is not one regular file');", - " if (mountOptionsFor(target).includes('ro')) fail('compiler-authored file is read-only');", - " return fd;", - " } catch (error) { try { fs.closeSync(fd); } catch {} fail('file has a symbolic-link or unavailable path component'); }", - "};", - "const ownTree = (fd, device, currentPath) => {", - " const info = fs.fstatSync(fd);", - " if (!info.isDirectory() || info.dev !== device) return;", - " fs.fchownSync(fd, uid, uid);", - " for (const entry of fs.readdirSync(`/proc/self/fd/${fd}`, { withFileTypes: true })) {", - " const childPath = `${currentPath}/${entry.name}`;", - " if (opaquePaths.has(childPath)) continue;", - " let child;", - " try { child = fs.openSync(`/proc/self/fd/${fd}/${entry.name}`, constants.O_RDONLY | constants.O_NOFOLLOW | (entry.isDirectory() ? constants.O_DIRECTORY : 0)); } catch { fail('state tree contains a symbolic link or unavailable entry'); }", - " try { const childInfo = fs.fstatSync(child); if (childInfo.dev !== device || mountOptionsFor(childPath).includes('ro')) continue; if (childInfo.isDirectory()) ownTree(child, device, childPath); else if (childInfo.isFile() && childInfo.nlink === 1) fs.fchownSync(child, uid, uid); else if (!childInfo.isFile()) fail('state tree contains an unsupported entry'); } finally { fs.closeSync(child); }", - " }", - "};", - "const securePrivateDirectory = (fd) => {", - " try {", - " fs.fchownSync(fd, 0, 0);", - " fs.fchmodSync(fd, 0o700);", - " fs.fchownSync(fd, uid, uid);", - " } catch {", - " try { fs.fchownSync(fd, uid, uid); } catch {}", - " fail('unable to secure private directory');", - " }", - " const info = fs.fstatSync(fd);", - " if (info.uid !== uid || info.gid !== uid || (info.mode & 0o777) !== 0o700) fail('private directory ownership or mode did not apply');", - "};", - "for (const target of privateDirectories) { if (opaquePaths.has(target)) fail('compiler-authored directory overlaps opaque path'); const fd = openDirectoryPath(target); try { fs.fchownSync(fd, uid, uid); } finally { fs.closeSync(fd); } }", - "for (const target of privateModeDirectories) { if (opaquePaths.has(target)) fail('private directory overlaps opaque path'); const fd = openDirectoryPath(target); try { securePrivateDirectory(fd); } finally { fs.closeSync(fd); } }", - "for (const target of privateFiles) { if (opaquePaths.has(target)) fail('compiler-authored file overlaps opaque path'); const fd = openRegularFilePath(target); try { fs.fchownSync(fd, uid, uid); } finally { fs.closeSync(fd); } }", - "for (const root of roots) { if (opaquePaths.has(root)) fail('state root overlaps opaque path'); const fd = openDirectoryPath(root); try { ownTree(fd, fs.fstatSync(fd).dev, root); } finally { fs.closeSync(fd); } }" -].join("\n"); - export const renderDaimonUidEntrypoint = ( runtimePlans: RuntimeTargetPlan[], persistentMountPaths: string[] = [], @@ -226,26 +249,41 @@ export const renderDaimonUidEntrypoint = ( persistentMountPaths, moltnet ); + const immutableRuntimeRoots = [ + ...new Set(runtimePlans + .filter((plan) => plan.runtimeName === "daimon") + .map((plan) => plan.runtimeRoot) + .filter((runtimeRoot) => runtimeRoot.startsWith("/opt/spawnfile/runtime-installs/"))) + ].sort(); + const volumeIdentityFiles = resolveDaimonVolumeIdentityFiles(runtimePlans); return [ "#!/usr/bin/env bash", "set -euo pipefail", - `uid="\${${DAIMON_AUTHORIZED_UID_ENV}:-${DAIMON_RUNTIME_UID}}"`, - 'case "$uid" in ""|*[!0-9]*|0) echo "Daimon authorized UID must be a nonzero integer" >&2; exit 1;; esac', + `uid=${DAIMON_ORGANIZATION_UID}`, 'gid="$uid"', - `runtime_command=(${quote("/opt/spawnfile/entrypoint.sh")})`, + `runtime_command=(${quote("/opt/spawnfile/entrypoint.sh")} --spawnfile-runtime-identity "$uid" "$gid")`, 'if [ "$#" -gt 0 ]; then', ` if [ "$#" -ne 5 ] || [ "$1" != auth ] || [ "$2" != agy ] || [ "$3" != login ] || [ "$4" != --config ] || [ "$5" != ${quote(daimonConfigPath ?? "")} ]; then echo "Unsupported Daimon container command" >&2; exit 1; fi`, ` runtime_command=(bash ${quote(daimonStartPath)} "$@")`, "fi", `state_roots=(${ownershipPlan.stateRoots.map(quote).join(" ")})`, "node - \"$uid\" \"${state_roots[@]}\" <<'SPAWNFILE_DAIMON_OWNERSHIP'", - renderOwnershipProgram( + renderDaimonOwnershipProgram( opaqueTargets, + ownershipPlan.opaqueDescendantRoots, + immutableRuntimeRoots, + volumeIdentityFiles, ownershipPlan.privateDirectories, ownershipPlan.privateFiles, - ownershipPlan.privateModeDirectories + ownershipPlan.privateModeDirectories, + ownershipPlan.creatablePrivateDirectories ), "SPAWNFILE_DAIMON_OWNERSHIP", + `for fixed_uid in ${DAIMON_ORGANIZATION_UID} ${DAIMON_BROKER_UID} ${resolveDaimonGrokRegistrations(runtimePlans).map((entry) => entry.uid).join(" ")}; do`, + ' if ! getent group "$fixed_uid" >/dev/null; then groupadd -K GID_MIN=1 --gid "$fixed_uid" "daimon-$fixed_uid"; fi', + ' if ! getent passwd "$fixed_uid" >/dev/null; then useradd -K UID_MIN=1 --no-create-home --no-log-init --uid "$fixed_uid" --gid "$fixed_uid" --home-dir /nonexistent --shell /usr/sbin/nologin "daimon-$fixed_uid"; fi', + "done", + ...renderDaimonBrokerProvisioning(runtimePlans), 'if ! getent passwd "$uid" >/dev/null; then', ' runtime_identity="daimon-$uid"', ' runtime_group="$(getent group "$gid" | cut -d: -f1 || true)"', @@ -253,11 +291,55 @@ 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', - "exec setpriv --clear-groups --reuid \"$uid\" --regid \"$gid\" --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '", - " if [ \"$EUID\" -eq 0 ]; then echo \"Daimon UID wrapper left root effective\" >&2; exit 1; fi", - " cap_eff=$(sed -n \"s/^CapEff:[[:space:]]*//p\" /proc/self/status)", - " if [ \"$cap_eff\" != \"0000000000000000\" ]; then echo \"Daimon UID wrapper retained effective capabilities\" >&2; exit 1; fi", + ...(resolveDaimonGrokRegistrations(runtimePlans).length === 0 ? [] : [ + ...renderDaimonBrokerSocketWait(), + "startup_children=()", + "cleanup_broker_startup() { status=$?; trap - EXIT; for child in \"${startup_children[@]}\"; do kill -TERM \"$child\" 2>/dev/null || true; wait \"$child\" 2>/dev/null || true; done; return \"$status\"; }", + "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`, + `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"'`, + `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}'` + ), + `setpriv --inh-caps=-all --ambient-caps=-all --bounding-set=-all,+chown,+setuid,+setgid -- ${quote(DAIMON_BROKER_EXECUTABLE)} &`, + "launcher_pid=$!", + "startup_children+=(\"$launcher_pid\")", + `wait_for_broker_socket ${quote(DAIMON_BROKER_LAUNCHER_SOCKET)} "$launcher_pid" "engine broker launcher"`, + `wait_for_broker_identity "$launcher_pid" 0 00000000000000c1 "engine broker launcher"`, + `setpriv --clear-groups --reuid ${DAIMON_BROKER_UID} --regid ${DAIMON_BROKER_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- ${quote(path.posix.join(daimonPlan?.runtimeRoot ?? "", "bin/daimon-runtime"))} engine-broker serve &`, + "broker_pid=$!", + "startup_children+=(\"$broker_pid\")", + `wait_for_broker_socket ${quote(DAIMON_BROKER_BACKEND_SOCKET)} "$broker_pid" "engine broker backend"`, + `wait_for_broker_identity "$broker_pid" ${DAIMON_BROKER_UID} 0000000000000000 "engine broker backend"`, + `setpriv --inh-caps=-all --ambient-caps=-all --bounding-set=-all,+chown,+setuid,+setgid,+setpcap -- ${quote(DAIMON_BROKER_EXECUTABLE)} --relay &`, + "relay_pid=$!", + "startup_children+=(\"$relay_pid\")", + `wait_for_broker_socket ${quote(DAIMON_BROKER_SOCKET)} "$relay_pid" "engine broker control relay"`, + `wait_for_broker_identity "$relay_pid" ${DAIMON_BROKER_UID} 0000000000000000 "engine broker control relay"`, + `[ -S ${quote(DAIMON_BROKER_SOCKET)} ] || exit 1`, + "for child_spec in \"$launcher_pid:0:00000000000000c1\" \"$relay_pid:2100:0000000000000000\" \"$broker_pid:2100:0000000000000000\"; do child=${child_spec%%:*}; rest=${child_spec#*:}; expected_uid=${rest%%:*}; expected_caps=${rest##*:}; observed_uid=$(awk '/^Uid:/{print $2}' \"/proc/$child/status\"); cap_bnd=$(awk '/^CapBnd:/{print $2}' \"/proc/$child/status\"); [ \"$observed_uid\" = \"$expected_uid\" ] && [ \"$cap_bnd\" = \"$expected_caps\" ] || exit 1; done" + ]), + "setpriv --clear-groups --reuid \"$uid\" --regid \"$gid\" --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '", + " if [ \"$EUID\" -eq 0 ]; then exit 1; fi", + " test \"$(sed -n \"s/^CapEff:[[:space:]]*//p\" /proc/self/status)\" = 0000000000000000", " exec \"$@\"", - `' bash "\${runtime_command[@]}"` + `' bash "\${runtime_command[@]}" &`, + "runtime_pid=$!", + "trap - EXIT TERM INT HUP", + "stopping=0", + "stop_children() { stopping=1; kill -TERM \"$runtime_pid\" 2>/dev/null || true; for child in \"${relay_pid:-}\" \"${broker_pid:-}\" \"${launcher_pid:-}\"; do [ -z \"$child\" ] || kill -TERM \"$child\" 2>/dev/null || true; done; }", + "trap stop_children TERM INT HUP", + 'watch_pids=("$runtime_pid")', + ...(resolveDaimonGrokRegistrations(runtimePlans).length === 0 ? [] : ['watch_pids+=("$relay_pid" "$broker_pid" "$launcher_pid")']), + "finished_pid=", + 'set +e; wait -n -p finished_pid "${watch_pids[@]}"; status=$?; set -e', + 'if [ "${finished_pid:-}" != "$runtime_pid" ]; then status=1; fi', + 'kill -TERM "$runtime_pid" 2>/dev/null || true', + "for child in \"${relay_pid:-}\" \"${broker_pid:-}\" \"${launcher_pid:-}\"; do [ -z \"$child\" ] || { kill -TERM \"$child\" 2>/dev/null || true; wait \"$child\" 2>/dev/null || true; }; done", + 'wait "$runtime_pid" 2>/dev/null || true', + "exit \"$status\"" ].join("\n") + "\n"; }; diff --git a/src/compiler/containerEntrypointRender.test.ts b/src/compiler/containerEntrypointRender.test.ts index 126a27d9..67302161 100644 --- a/src/compiler/containerEntrypointRender.test.ts +++ b/src/compiler/containerEntrypointRender.test.ts @@ -60,6 +60,62 @@ const runtimePlan = (overrides: Partial = {}): RuntimeTargetP }); describe("renderEntrypoint network binding", () => { + it("rejects noncanonical or spoofed internal Daimon runtime identities", () => { + const script = renderEntrypoint([runtimePlan({ runtimeName: "daimon" })], []); + const rejected = [ + [], + ["--spawnfile-runtime-identity", "", "2000"], + ["--spawnfile-runtime-identity", "0", "2000"], + ["--spawnfile-runtime-identity", "00", "2000"], + ["--spawnfile-runtime-identity", "02000", "2000"], + ["--spawnfile-runtime-identity", "2000", ""], + ["--spawnfile-runtime-identity", "2000", "0"], + ["--spawnfile-runtime-identity", "2000", "00"], + ["--spawnfile-runtime-identity", "2000", "02000"], + ["--spawnfile-runtime-identity", "2:000", "2000"], + ["--spawnfile-runtime-identity", "2147483648", "2000"], + ["--spawnfile-runtime-identity", "2000", "2147483648"] + ]; + for (const args of rejected) { + const result = spawnSync("bash", ["-c", script, "entrypoint-test", ...args], { + env: { + ...process.env, + SPAWNFILE_DAIMON_AUTHORIZED_UID: "2000", + volume_bootstrap_uid: "2000", + volume_bootstrap_gid: "2000" + } + }); + expect(result.status, args.join(" ")).not.toBe(0); + expect(result.stderr.toString()).toMatch(/trusted Daimon runtime (identity|UID|GID)/u); + } + }); + + it("fails readiness immediately when the launched Daimon child exits", () => { + const root = mkdtempSync(join(tmpdir(), "spawnfile-daimon-readiness-exit-")); + const configPath = join(root, "config.json"); + writeFileSync(configPath, "{}\n"); + const script = renderEntrypoint([runtimePlan({ + id: "daimon-exit", + runtimeName: "daimon", + port: 59999, + instancePaths: { configPath, workspacePath: join(root, "workspace") }, + meta: { ...runtimePlan().meta, startCommand: ["bash", "-c", "exit 23"] } + })], [], { hasMoltnet: true, moltnet: { nodePlans: [nodePlan()], serverPlans: [] } }); + const started = Date.now(); + const result = spawnSync("bash", [ + "-c", script, "entrypoint-test", "--spawnfile-runtime-identity", "2000", "2000" + ], { timeout: 5_000 }); + + expect(result.status).not.toBe(0); + expect(Date.now() - started).toBeLessThan(4_000); + expect(result.stderr.toString()).toContain("Daimon exited before readiness"); + }); + + it("fails fast when a critical Daimon or Moltnet child exits", () => { + const script = renderEntrypoint([runtimePlan({ id: "daimon-organization", runtimeName: "daimon", port: 19700, engineByNodeId: { "agent:press": "grok", "agent:desk": "codex" } })], [], { hasMoltnet: true, moltnet: { nodePlans: [nodePlan()], serverPlans: [] } }); + expect(script).toContain("/healthz"); + expect(script).toContain('wait -n "${PIDS[@]}"'); + }); it("suppresses the in-image managed server when the network URL is bound", () => { const script = renderEntrypoint([], [], { hasMoltnet: true, @@ -68,7 +124,9 @@ describe("renderEntrypoint network binding", () => { expect(script).toContain('if [ -z "${SPAWNFILE_NETWORK_DIST_LAB_URL:-}" ]; then'); // The server start and healthz wait live inside the guarded block. const guardIndex = script.indexOf("SPAWNFILE_NETWORK_DIST_LAB_URL"); - const serverIndex = script.indexOf("/usr/local/bin/moltnet &"); + const serverIndex = script.indexOf( + "/usr/local/bin/moltnet start --config '/var/lib/spawnfile/moltnet/servers/org-dist_lab/Moltnet.json' &" + ); expect(serverIndex).toBeGreaterThan(guardIndex); expect(script).toContain("/healthz"); }); @@ -163,7 +221,8 @@ describe("renderEntrypoint managed moltnet recipeEnv propagation", () => { expect(script).toContain( "MOLTNET_CONFIG='/var/lib/spawnfile/moltnet/servers/org-dist_lab/Moltnet.json' " + "MOLTNET_CAUSAL_EVENTS_PATH='/var/lib/spawnfile/moltnet/servers/org-dist_lab/causal/causal.jsonl' " + - "NOOPOLIS_RUN_ID='run-abc123' /usr/local/bin/moltnet &" + "NOOPOLIS_RUN_ID='run-abc123' /usr/local/bin/moltnet start --config " + + "'/var/lib/spawnfile/moltnet/servers/org-dist_lab/Moltnet.json' &" ); // B93's per-target exec-time prefix must still carry the value too. expect(script).toContain("NOOPOLIS_RUN_ID='run-abc123'"); @@ -199,7 +258,8 @@ describe("renderEntrypoint managed moltnet recipeEnv propagation", () => { expect(script).toContain( "MOLTNET_CONFIG='/var/lib/spawnfile/moltnet/servers/org-dist_lab/Moltnet.json' " + "MOLTNET_CAUSAL_EVENTS_PATH='/var/lib/spawnfile/moltnet/servers/org-dist_lab/causal/causal.jsonl' " + - "/usr/local/bin/moltnet &" + "/usr/local/bin/moltnet start --config " + + "'/var/lib/spawnfile/moltnet/servers/org-dist_lab/Moltnet.json' &" ); expect(script).toContain( "/usr/local/bin/moltnet node '/var/lib/spawnfile/moltnet/nodes/dist_lab.json' &" @@ -249,7 +309,7 @@ describe("renderEntrypoint managed moltnet causal events path", () => { const launchLine = script .split("\n") - .find((line) => line.includes("/usr/local/bin/moltnet &")); + .find((line) => line.includes("/usr/local/bin/moltnet start --config")); expect(launchLine).toBeDefined(); expect(launchLine).toContain("MOLTNET_CAUSAL_EVENTS_PATH="); expect(launchLine).toContain("NOOPOLIS_RUN_ID='run-abc123'"); diff --git a/src/compiler/containerEntrypointRender.ts b/src/compiler/containerEntrypointRender.ts index 22502062..7e8ff6d6 100644 --- a/src/compiler/containerEntrypointRender.ts +++ b/src/compiler/containerEntrypointRender.ts @@ -22,6 +22,7 @@ import { mergeRecipeEnv, shellQuote } from "./containerEntrypointShell.js"; +import { MOLTNET_READINESS_DIRECTORY } from "./containerReadinessPaths.js"; const MOLTNET_SERVER_DATA_DIRECTORY = "/var/lib/spawnfile/moltnet/servers"; @@ -110,13 +111,14 @@ const resolveStartCommand = (plan: RuntimeTargetPlan): string[] => ) .filter((token) => token.length > 0); -const createRuntimeReadinessWait = (plan: RuntimeTargetPlan): string[] => { +const createRuntimeReadinessWait = (plan: RuntimeTargetPlan, pidVariable: string): string[] => { if (!plan.port) return []; if (plan.runtimeName === "daimon") { return [ "attempts=0", `until curl -sf ${shellQuote(`http://127.0.0.1:${plan.port}/healthz`)} >/dev/null; do`, + ` if ! kill -0 "$${pidVariable}" 2>/dev/null; then wait "$${pidVariable}" || true; echo ${shellQuote(`Daimon exited before readiness on port ${plan.port}`)} >&2; exit 1; fi`, " attempts=$((attempts + 1))", ' if [ "$attempts" -ge 180 ]; then', ` echo ${shellQuote(`Timed out waiting for daimon on port ${plan.port}`)} >&2`, @@ -133,6 +135,7 @@ const createRuntimeReadinessWait = (plan: RuntimeTargetPlan): string[] => { return [ "attempts=0", `until curl -sf ${shellQuote(`http://127.0.0.1:${plan.port}/healthz`)} >/dev/null; do`, + ` if ! kill -0 "$${pidVariable}" 2>/dev/null; then wait "$${pidVariable}" || true; echo ${shellQuote(`${plan.runtimeName} exited before readiness on port ${plan.port}`)} >&2; exit 1; fi`, " attempts=$((attempts + 1))", ' if [ "$attempts" -ge 180 ]; then', ` echo ${shellQuote(`Timed out waiting for ${plan.runtimeName} on port ${plan.port}`)} >&2`, @@ -147,6 +150,7 @@ const createRuntimeReadinessWait = (plan: RuntimeTargetPlan): string[] => { export interface EntrypointOptions { hasMoltnet?: boolean; hasStagedMoltnetBinaries?: boolean; + hasWorkspaceBundles?: boolean; moltnet?: { externalParticipantArtifacts?: NonNullable; nodePlans: MoltnetArtifacts["nodePlans"]; @@ -161,6 +165,7 @@ export const renderEntrypoint = ( requiredSecrets: string[], options: EntrypointOptions = {} ): string => { + const usesDaimonRuntime = runtimePlans.some((plan) => plan.runtimeName === "daimon"); const cliCredentialMaterialization = createCliCredentialMaterialization(runtimePlans); const renderedRequiredSecrets = [ ...new Set([ @@ -171,6 +176,17 @@ export const renderEntrypoint = ( const lines = [ "#!/usr/bin/env bash", "set -euo pipefail", + ...(usesDaimonRuntime ? [ + 'if [ "$#" -ne 3 ] || [ "$1" != "--spawnfile-runtime-identity" ]; then echo "Missing trusted Daimon runtime identity" >&2; exit 1; fi', + 'volume_bootstrap_uid="$2"', + 'volume_bootstrap_gid="$3"', + 'if ! [[ "$volume_bootstrap_uid" =~ ^[1-9][0-9]{0,9}$ ]] || [ "$volume_bootstrap_uid" -gt 2147483647 ]; then echo "Invalid trusted Daimon runtime UID" >&2; exit 1; fi', + 'if ! [[ "$volume_bootstrap_gid" =~ ^[1-9][0-9]{0,9}$ ]] || [ "$volume_bootstrap_gid" -gt 2147483647 ]; then echo "Invalid trusted Daimon runtime GID" >&2; exit 1; fi', + "shift 3" + ] : [ + "volume_bootstrap_uid=1001", + "volume_bootstrap_gid=1001" + ]), "", "require_env() {", ' local name=\"$1\"', @@ -258,6 +274,13 @@ export const renderEntrypoint = ( "" ); + for (const receiptDirectory of [...new Set(moltnetNodePlans.flatMap((plan) => + plan.receiptStorePath ? [path.posix.dirname(plan.receiptStorePath)] : [] + ))].sort()) { + lines.push(`install -d -m 700 ${shellQuote(receiptDirectory)}`); + } + if (moltnetNodePlans.some((plan) => plan.receiptStorePath)) lines.push(""); + const recipeEnvAssignments = createRecipeEnvAssignments(mergeRecipeEnv(runtimePlans)); const recipeEnvPrefix = recipeEnvAssignments.length > 0 ? `${recipeEnvAssignments.join(" ")} ` : ""; @@ -282,12 +305,13 @@ export const renderEntrypoint = ( const causalEventsPath = moltnetCausalEventsPath(serverPlan.configPath); serverLines.push( ...createMoltnetStorePrepareCommands(serverPlan), - `MOLTNET_CONFIG=${shellQuote(serverPlan.configPath)} MOLTNET_CAUSAL_EVENTS_PATH=${shellQuote(causalEventsPath)} ${recipeEnvPrefix}/usr/local/bin/moltnet &`, - 'PIDS+=("$!")' + `MOLTNET_CONFIG=${shellQuote(serverPlan.configPath)} MOLTNET_CAUSAL_EVENTS_PATH=${shellQuote(causalEventsPath)} ${recipeEnvPrefix}/usr/local/bin/moltnet start --config ${shellQuote(serverPlan.configPath)} &`, + 'moltnet_server_pid="$!"', + 'PIDS+=("$moltnet_server_pid")' ); if (serverPlan.port) { serverLines.push( - `until curl -sf ${shellQuote(`http://127.0.0.1:${serverPlan.port}/healthz`)} >/dev/null; do sleep 1; done` + `until curl -sf ${shellQuote(`http://127.0.0.1:${serverPlan.port}/healthz`)} >/dev/null; do if ! kill -0 "$moltnet_server_pid" 2>/dev/null; then wait "$moltnet_server_pid" || true; echo ${shellQuote(`Moltnet exited before readiness on port ${serverPlan.port}`)} >&2; exit 1; fi; sleep 1; done` ); } const externalParticipants = moltnetExternalParticipants.filter( @@ -338,20 +362,24 @@ export const renderEntrypoint = ( ...createEnvFileWrites(plan), ...createConfigEnvWrites(plan), `${envAssignments.join(" ")} ${commandTokens.map(shellQuote).join(" ")} &`, - 'PIDS+=("$!")', + 'runtime_pid="$!"', + 'PIDS+=("$runtime_pid")', "", - ...createRuntimeReadinessWait(plan) + ...createRuntimeReadinessWait(plan, "runtime_pid") ); } for (const nodePlan of moltnetNodePlans) { const urlEnv = networkUrlEnvName(nodePlan.networkId); + const receiptPath = `${MOLTNET_READINESS_DIRECTORY}/${nodePlan.networkId}-${nodePlan.memberId}.json`; lines.push( // Rebind the bridge endpoint when an external network URL is provided. `if [ -n "\${${urlEnv}:-}" ]; then`, ` apply_json_env_value ${shellQuote(nodePlan.configPath)} ${shellQuote(urlEnv)} ${shellQuote("moltnet.base_url")}`, "fi", - `${recipeEnvPrefix}/usr/local/bin/moltnet node ${shellQuote(nodePlan.configPath)} &`, + `install -d -m 700 ${shellQuote(path.posix.dirname(receiptPath))}`, + `rm -f ${shellQuote(receiptPath)}`, + `MOLTNET_NODE_READINESS_RECEIPT=${shellQuote(receiptPath)} ${recipeEnvPrefix}/usr/local/bin/moltnet node ${shellQuote(nodePlan.configPath)} &`, 'PIDS+=("$!")', "" ); @@ -363,13 +391,15 @@ export const renderEntrypoint = ( " exit 1", "fi", "", - "status=0", - 'for pid in "${PIDS[@]}"; do', - ' if ! wait "$pid"; then', - " status=1", - " fi", - "done", - "", + "# Every child is critical. Exit on the first child termination so Docker", + "# can restart the complete, mutually consistent organization unit.", + "set +e", + 'wait -n "${PIDS[@]}"', + "status=$?", + "set -e", + "terminate_children", + 'for pid in "${PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done', + 'if [ "$status" -eq 0 ]; then status=1; fi', 'exit "$status"' ); diff --git a/src/compiler/containerReadinessPaths.ts b/src/compiler/containerReadinessPaths.ts new file mode 100644 index 00000000..e1724202 --- /dev/null +++ b/src/compiler/containerReadinessPaths.ts @@ -0,0 +1 @@ +export const MOLTNET_READINESS_DIRECTORY = "/run/spawnfile/moltnet-readiness"; diff --git a/src/compiler/containerRuntimeLinkMaterializer.ts b/src/compiler/containerRuntimeLinkMaterializer.ts new file mode 100644 index 00000000..d3f28577 --- /dev/null +++ b/src/compiler/containerRuntimeLinkMaterializer.ts @@ -0,0 +1,13 @@ +export const RUNTIME_LINK_MATERIALIZER_PATH = "/opt/spawnfile/materialize-runtime-links.cjs"; + +export const renderRuntimeLinkMaterializer = (): string => `"use strict"; +const fs=require("node:fs"),path=require("node:path"),root=path.resolve(process.argv[2]||""); +if(!root.startsWith("/opt/spawnfile/runtime-installs/"))throw Error("unsafe root"); +const rootInfo=fs.lstatSync(root),links=[]; +if(!rootInfo.isDirectory()||rootInfo.isSymbolicLink()||rootInfo.uid||rootInfo.gid)throw Error("unsafe root identity"); +const walk=d=>{for(const n of fs.readdirSync(d).sort()){const p=path.join(d,n),s=fs.lstatSync(p);if(s.isSymbolicLink())links.push(p);else if(s.isDirectory())walk(p);else if(!s.isFile())throw Error("unsupported entry");}}; +const inside=p=>p.startsWith(root+path.sep); +const resolve=p=>{let hops=0;for(;;){const rel=path.relative(root,p);if(!rel||rel.startsWith("..")||path.isAbsolute(rel))throw Error("link escape");const parts=rel.split(path.sep);let q=root,again=false;for(let i=0;i16||s.uid||s.gid)throw Error("unsafe link chain");const l=fs.readlinkSync(q);if(path.isAbsolute(l))throw Error("absolute link");p=path.resolve(path.dirname(q),l,...parts.slice(i+1));if(!inside(p))throw Error("link escape");again=true;break;}if(!again)return p;}}; +walk(root); +for(const link of links){const s=fs.lstatSync(link);if(!s.isSymbolicLink()||s.uid||s.gid)throw Error("link changed");const target=resolve(link),t=fs.lstatSync(target);if(!t.isFile()||t.isSymbolicLink()||t.uid||t.gid||t.dev!==rootInfo.dev||t.nlink<1)throw Error("unsafe target");const b=fs.readFileSync(target);if(b.length>67108864)throw Error("target too large");const tmp=link+".spawnfile-materialize",fd=fs.openSync(tmp,fs.constants.O_CREAT|fs.constants.O_EXCL|fs.constants.O_WRONLY|fs.constants.O_NOFOLLOW,t.mode&0o111?0o555:0o444);try{fs.writeFileSync(fd,b);fs.fsyncSync(fd);}finally{fs.closeSync(fd);}fs.renameSync(tmp,link);} +`; diff --git a/src/compiler/containerStateOwnershipRender.ts b/src/compiler/containerStateOwnershipRender.ts index 0e675b8b..7e7c88de 100644 --- a/src/compiler/containerStateOwnershipRender.ts +++ b/src/compiler/containerStateOwnershipRender.ts @@ -8,6 +8,8 @@ import { resolveDaimonUidEntrypointStateRoots } from "./containerDaimonUidEntrypointRender.js"; import type { EntrypointOptions } from "./containerEntrypointRender.js"; +import { VOLUME_BOOTSTRAP_MARKER,VOLUME_BOOTSTRAP_MARKER_CONTENT } from "./containerVolumeBootstrap.js"; +export { VOLUME_BOOTSTRAP_MARKER,VOLUME_BOOTSTRAP_MARKER_CONTENT } from "./containerVolumeBootstrap.js"; const SPAWNFILE_STATE_ROOT = "/var/lib/spawnfile"; const MOLTNET_STATE_ROOT = `${SPAWNFILE_STATE_ROOT}/moltnet`; @@ -44,6 +46,9 @@ const createMoltnetPrivacyCommands = ( ), ...moltnet.nodePlans.map((plan) => plan.configPath) ].sort(); + const receiptDirectories = moltnet.nodePlans.flatMap((plan) => + plan.receiptStorePath ? [path.posix.dirname(plan.receiptStorePath)] : [] + ); if (configPaths.length === 0) return []; if (configPaths.some((configPath) => !configPath.startsWith(`${MOLTNET_STATE_ROOT}/`))) { throw new SpawnfileError( @@ -59,9 +64,10 @@ const createMoltnetPrivacyCommands = ( ...configPaths.flatMap((configPath) => privateDirectoriesThrough(path.posix.dirname(configPath)) ), + ...receiptDirectories.flatMap(privateDirectoriesThrough), ...moltnetMountPaths.flatMap(privateDirectoriesThrough) ]) - ].sort(); + ].filter((directory) => directory !== SPAWNFILE_STATE_ROOT).sort(); const ownership = `${DAIMON_RUNTIME_UID}:${DAIMON_RUNTIME_UID}`; return [ @@ -82,11 +88,11 @@ export const createStateOwnershipCommand = ( : []; const mkdirPaths = [...new Set([SPAWNFILE_STATE_ROOT, ...mountPaths])].sort(); const markerCommands = mountPaths.map((mountPath) => - `touch ${shellQuote(path.posix.join(mountPath, ".spawnfile-volume-init"))}` + `printf '%s\\n' ${shellQuote(VOLUME_BOOTSTRAP_MARKER_CONTENT)} > ${shellQuote(path.posix.join(mountPath, VOLUME_BOOTSTRAP_MARKER))} && chmod 600 ${shellQuote(path.posix.join(mountPath, VOLUME_BOOTSTRAP_MARKER))}` ); const chownPaths = [ ...new Set([ - SPAWNFILE_STATE_ROOT, + ...(runtimePlans.some((plan) => plan.runtimeName === "daimon") ? [] : [SPAWNFILE_STATE_ROOT]), ...mountPaths.filter((mountPath) => !mountPath.startsWith(`${SPAWNFILE_STATE_ROOT}/`)) ]) ].sort(); @@ -94,7 +100,12 @@ export const createStateOwnershipCommand = ( return [ `mkdir -p ${mkdirPaths.map(shellQuote).join(" ")}`, ...markerCommands, - `chown -R spawnfile:spawnfile ${chownPaths.map(shellQuote).join(" ")}`, + ...(chownPaths.length > 0 + ? [`chown -R spawnfile:spawnfile ${chownPaths.map(shellQuote).join(" ")}`] + : []), + ...(runtimePlans.some((plan) => plan.runtimeName === "daimon") + ? [`chown root:root ${shellQuote(SPAWNFILE_STATE_ROOT)} && chmod 711 ${shellQuote(SPAWNFILE_STATE_ROOT)}`] + : []), ...wrapperStateRoots.map( (stateRoot) => `install -d -o root -g root -m 700 ${shellQuote(stateRoot)}` ), diff --git a/src/compiler/containerTargetResources.ts b/src/compiler/containerTargetResources.ts index 645bc3c7..42cb5d00 100644 --- a/src/compiler/containerTargetResources.ts +++ b/src/compiler/containerTargetResources.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { createHash } from "node:crypto"; import type { ContainerTarget, @@ -6,6 +7,7 @@ import type { RuntimeContainerMeta } from "../runtime/index.js"; import { SpawnfileError } from "../shared/index.js"; +import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import { @@ -17,6 +19,47 @@ const CONFIG_FILE_PLACEHOLDER = ""; const INSTANCE_ROOT_PLACEHOLDER = ""; const SOURCE_AGENT_PLACEHOLDER = ""; const SOURCE_SLUG_PLACEHOLDER = ""; +const RESOURCE_VOLUME_PREFIX = "spawnfile-workspace-resource"; + +export interface ResolvedTargetResourcePlan extends WorkspaceResourcePlan { + canonicalBackingPath: string; + ownerId: string; + persistentMountId?: string; + replacementSentinel?: string; + resolvedIdentity: string; + volumeName?: string; +} + +export interface WorkspaceResourcePersistentMount { + id: string; + mount_path: string; + reason: string; + volume_name: string; +} + +const digest = (value: string): string => createHash("sha256").update(value).digest("hex"); + +export const resolveWorkspaceResourceVolumes = ( + runtimePlans: readonly RuntimeTargetPlan[] +): { resources: ResolvedTargetResourcePlan[]; mounts: WorkspaceResourcePersistentMount[] } => { + const resources = runtimePlans.flatMap((runtimePlan) => (runtimePlan.resources ?? []) as ResolvedTargetResourcePlan[]); + const byBackingPath = new Map(); + const backingPathByVolumeName = new Map(); + for (const resource of resources.filter((candidate) => candidate.kind === "volume")) { + const existing = byBackingPath.get(resource.canonicalBackingPath); + if (existing && (existing.mode !== resource.mode || existing.sharing !== resource.sharing || existing.volumeName !== resource.volumeName || (resource.sharing === "per_agent" && existing.ownerId !== resource.ownerId))) { + throw new SpawnfileError("validation_error", `Workspace volume ${resource.canonicalBackingPath} has incompatible mode, sharing, or owner declarations`); + } + const conflictingPath = backingPathByVolumeName.get(resource.volumeName!); + if (conflictingPath && conflictingPath !== resource.canonicalBackingPath) throw new SpawnfileError("validation_error", "Workspace volume name collision"); + byBackingPath.set(resource.canonicalBackingPath, resource); + backingPathByVolumeName.set(resource.volumeName!, resource.canonicalBackingPath); + } + return { resources, mounts: [...byBackingPath.values()].map((resource) => ({ + id: resource.persistentMountId!, mount_path: resource.canonicalBackingPath, + reason: `Workspace ${resource.sharing} resource ${resource.id}`, volume_name: resource.volumeName! + })) }; +}; const replaceSourceWorkspacePathTemplate = ( template: string, @@ -59,14 +102,16 @@ const pathsOverlap = (left: string, right: string): boolean => const dedupeAndAssertResourcePlans = ( target: ContainerTarget, - resources: WorkspaceResourcePlan[] -): WorkspaceResourcePlan[] => { - const byLinkPath = new Map(); + resources: ResolvedTargetResourcePlan[] +): ResolvedTargetResourcePlan[] => { + const byLinkPath = new Map(); + const volumesByBackingPath = new Map(); + const backingPathByVolumeName = new Map(); for (const resource of resources) { const existing = byLinkPath.get(resource.linkPath); if (existing) { - if (existing.backingPath !== resource.backingPath || existing.id !== resource.id) { + if (existing.resolvedIdentity !== resource.resolvedIdentity) { throw new SpawnfileError( "validation_error", `Container target ${target.id} declares conflicting workspace resources at ${resource.linkPath}` @@ -86,6 +131,27 @@ const dedupeAndAssertResourcePlans = ( } byLinkPath.set(resource.linkPath, resource); + if (resource.kind !== "volume") continue; + const existingVolume = volumesByBackingPath.get(resource.canonicalBackingPath); + if (existingVolume) { + const incompatible = existingVolume.mode !== resource.mode || + existingVolume.sharing !== resource.sharing || + existingVolume.volumeName !== resource.volumeName || + (resource.sharing === "per_agent" && existingVolume.ownerId !== resource.ownerId); + if (incompatible) { + throw new SpawnfileError( + "validation_error", + `Container target ${target.id} declares incompatible workspace volume owners or modes at ${resource.canonicalBackingPath}` + ); + } + } else { + volumesByBackingPath.set(resource.canonicalBackingPath, resource); + } + const conflictingBackingPath = backingPathByVolumeName.get(resource.volumeName!); + if (conflictingBackingPath && conflictingBackingPath !== resource.canonicalBackingPath) { + throw new SpawnfileError("validation_error", `Container target ${target.id} workspace volume identity collision`); + } + backingPathByVolumeName.set(resource.volumeName!, resource.canonicalBackingPath); } return [...byLinkPath.values()].sort( @@ -97,15 +163,17 @@ export const resolveTargetResources = ( target: ContainerTarget, inputs: ContainerTargetInput[], instancePaths: RuntimeTargetPlan["instancePaths"], - meta: RuntimeContainerMeta -): WorkspaceResourcePlan[] => { + meta: RuntimeContainerMeta, + planRoot: string, + runId?: string +): ResolvedTargetResourcePlan[] => { const sourceIds = new Set(target.sourceIds ?? []); if (sourceIds.size === 0) { return []; } const isMergedTarget = sourceIds.size > 1; - const resources = inputs.flatMap((input) => { + const resources = inputs.flatMap((input): ResolvedTargetResourcePlan[] => { if (!sourceIds.has(input.id) || input.value.kind !== "agent") { return []; } @@ -117,7 +185,30 @@ export const resolveTargetResources = ( targetId: sourceTargetId(target, input, isMergedTarget), workspacePath: resolveSourceWorkspacePath(input, instancePaths, meta) } - ); + ).map((resource) => { + const canonicalBackingPath = path.posix.normalize(resource.backingPath); + const ownerId = resource.sharing === "per_agent" ? input.id : canonicalBackingPath; + const resolvedIdentity = `sha256:${digest(JSON.stringify({ + backingPath: canonicalBackingPath, + kind: resource.kind, + mode: resource.mode, + ownerId, + ...(resource.kind === "bundle" ? { archivePath: resource.archivePath, sha256: resource.sha256 } : {}), + sharing: resource.sharing + }))}`; + if (resource.kind !== "volume") return { ...resource, backingPath: canonicalBackingPath, canonicalBackingPath, ownerId, resolvedIdentity }; + const pathDigest = digest(canonicalBackingPath); + return { + ...resource, + backingPath: canonicalBackingPath, + canonicalBackingPath, + ownerId, + persistentMountId: `workspace-resource-${pathDigest.slice(0, 24)}`, + replacementSentinel: path.posix.join(canonicalBackingPath, ".spawnfile-resource-identity"), + resolvedIdentity, + volumeName: runId ? createPersistentVolumeName(planRoot, `${RESOURCE_VOLUME_PREFIX}-${pathDigest.slice(0, 24)}`, undefined, runId) : `${RESOURCE_VOLUME_PREFIX}-${pathDigest.slice(0, 24)}` + }; + }); }); return dedupeAndAssertResourcePlans(target, resources); diff --git a/src/compiler/containerVolumeBootstrap.ts b/src/compiler/containerVolumeBootstrap.ts new file mode 100644 index 00000000..153d02a9 --- /dev/null +++ b/src/compiler/containerVolumeBootstrap.ts @@ -0,0 +1,3 @@ +export const VOLUME_BOOTSTRAP_MARKER = ".spawnfile-volume-init"; +export const VOLUME_BOOTSTRAP_CLAIM = `${VOLUME_BOOTSTRAP_MARKER}.claim`; +export const VOLUME_BOOTSTRAP_MARKER_CONTENT = "spawnfile.volume-bootstrap.v1"; diff --git a/src/compiler/containerWorkspaceResourceRender.ts b/src/compiler/containerWorkspaceResourceRender.ts index 8a3d02c1..85f31f4d 100644 --- a/src/compiler/containerWorkspaceResourceRender.ts +++ b/src/compiler/containerWorkspaceResourceRender.ts @@ -1,4 +1,5 @@ import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import { VOLUME_BOOTSTRAP_MARKER, VOLUME_BOOTSTRAP_MARKER_CONTENT } from "./containerVolumeBootstrap.js"; const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\"'\"'`)}'`; @@ -6,10 +7,14 @@ export const createWorkspaceResourceCommands = (plan: RuntimeTargetPlan): string (plan.resources ?? []).flatMap((resource) => { const readonlyFlag = resource.mode === "readonly" ? "readonly" : "mutable"; if (resource.kind === "volume") { + const resolved = resource as typeof resource & { resolvedIdentity?: string }; return [ - `prepare_volume_resource ${shellQuote(resource.id)} ${shellQuote(resource.linkPath)} ${shellQuote(resource.backingPath)} ${shellQuote(readonlyFlag)}` + `prepare_volume_resource ${shellQuote(resource.id)} ${shellQuote(resource.linkPath)} ${shellQuote(resource.backingPath)} ${shellQuote(readonlyFlag)} ${shellQuote(resolved.resolvedIdentity ?? "")}` ]; } + if (resource.kind === "bundle") return [ + `prepare_bundle_resource ${shellQuote(resource.id)} ${shellQuote(resource.linkPath)} ${shellQuote(resource.backingPath)} ${shellQuote(resource.archivePath ?? "")} ${shellQuote(resource.sha256 ?? "")}` + ]; const selectorKind = resource.branch ? "branch" @@ -65,15 +70,57 @@ export const createWorkspaceResourceShellFunctions = (): string[] => [ ' local link_path="$2"', ' local backing_path="$3"', ' local mode="$4"', + ' local identity="$5"', ' mkdir -p "$backing_path"', ' if [ ! -d "$backing_path" ]; then', ' echo "Workspace volume resource $id backing path is not a directory: $backing_path" >&2', " exit 1", " fi", + ' local sentinel="$backing_path/.spawnfile-resource-identity"', + ' if [ -f "$sentinel" ]; then', + ' if [ "$(cat "$sentinel")" != "$identity" ]; then', + ' echo "Workspace volume resource $id replacement sentinel mismatch" >&2', + " exit 1", + " fi", + " else", + ` local bootstrap="$backing_path/${VOLUME_BOOTSTRAP_MARKER}"`, + ` local bootstrap_claim="$backing_path/${VOLUME_BOOTSTRAP_MARKER}.claim"`, + ' local first_entry', + ' first_entry="$(find "$backing_path" -mindepth 1 -maxdepth 1 -print -quit)"', + ' if [ -n "$first_entry" ]; then', + ' if [ "$first_entry" = "$bootstrap" ]; then', + ' mv -T --no-clobber -- "$bootstrap" "$bootstrap_claim"', + ' elif [ "$first_entry" != "$bootstrap_claim" ]; then', + ' echo "Workspace volume resource $id is nonempty without an authenticated replacement sentinel" >&2', + " exit 1", + " fi", + ' if [ "$(find "$backing_path" -mindepth 1 -maxdepth 1 ! -name ' + `'${VOLUME_BOOTSTRAP_MARKER}.claim'` + ' -print -quit)" != "" ] || [ -L "$bootstrap_claim" ] || [ ! -f "$bootstrap_claim" ] || [ "$(stat -c "%u:%g:%a:%h" "$bootstrap_claim")" != "${volume_bootstrap_uid}:${volume_bootstrap_gid}:600:1" ] || [ "$(cat "$bootstrap_claim")" != "' + VOLUME_BOOTSTRAP_MARKER_CONTENT + '" ]; then', + ' echo "Workspace volume resource $id bootstrap marker mismatch" >&2', + " exit 1", + " fi", + ' rm -- "$bootstrap_claim"', + " fi", + ' printf "%s\\n" "$identity" > "$sentinel"', + " fi", ' mark_readonly_resource "$backing_path" "$mode"', ' prepare_resource_link "$id" "$link_path" "$backing_path"', "}", "", + "prepare_bundle_resource() {", + ' local id="$1" link_path="$2" backing_path="$3" archive="$4" identity="$5"', + ' [ -f "$archive" ] || { echo "Workspace bundle $id archive missing" >&2; exit 1; }', + ' local sentinel="$backing_path/.spawnfile-bundle-identity"', + ' if [ -e "$backing_path" ]; then', + ' [ -f "$sentinel" ] && [ "$(cat "$sentinel")" = "$identity" ] || { echo "Workspace bundle $id identity mismatch" >&2; exit 1; }', + " else", + ' mkdir -p "$backing_path"', + ' tar --no-same-owner --no-same-permissions -xf "$archive" -C "$backing_path"', + ' printf %s "$identity" > "$sentinel"', + " fi", + ' mark_readonly_resource "$backing_path" readonly', + ' prepare_resource_link "$id" "$link_path" "$backing_path"', + "}", + "", "prepare_git_resource() {", ' local id="$1"', ' local link_path="$2"', diff --git a/src/compiler/moltnetArtifactPaths.test.ts b/src/compiler/moltnetArtifactPaths.test.ts index 7001c978..c3ab6cd1 100644 --- a/src/compiler/moltnetArtifactPaths.test.ts +++ b/src/compiler/moltnetArtifactPaths.test.ts @@ -26,10 +26,10 @@ describe("createPersistentVolumeName literal formula", () => { expect(name).toContain("-run-alpha-"); }); - it("(c) an explicit persistence.name wins verbatim, even over a runId", () => { + it("(c) scopes an explicit persistence.name when a deployment run id is present", () => { const name = createPersistentVolumeName(PLAN_ROOT, MOUNT_ID, "my-explicit-volume", "run-alpha"); - expect(name).toBe("my-explicit-volume"); + expect(name).toMatch(/^spawnfile-team-my-explicit-volume-run-alpha-[0-9a-f]{8}$/u); }); it("(d) two different runIds produce two different volume names (run isolation)", () => { diff --git a/src/compiler/moltnetArtifactPaths.ts b/src/compiler/moltnetArtifactPaths.ts index 13f4863e..f72c8af3 100644 --- a/src/compiler/moltnetArtifactPaths.ts +++ b/src/compiler/moltnetArtifactPaths.ts @@ -38,10 +38,9 @@ const truncateSegment = (value: string, maxLength: number): string => * the host env) reproduces the pre-run-scoping name exactly, so standard * compiles stay byte-identical. * - * `explicitName` (an author-declared `persistence.name`) always wins over - * both project/id and run scoping — an explicit name is opt-in shared/ - * reused state by author intent, not something this helper should silently - * fragment across runs. + * `explicitName` (an author-declared `persistence.name`) remains verbatim for + * a bare compile. During a run it is namespaced by the run identity, preventing + * blue/green candidates from accidentally sharing live durable state. */ export const createPersistentVolumeName = ( planRoot: string, @@ -49,13 +48,10 @@ export const createPersistentVolumeName = ( explicitName?: string, runId?: string ): string => { - if (explicitName && explicitName.trim().length > 0) { - return explicitName.trim(); - } - const project = truncateSegment(slugify(planRoot.split("/").slice(-2, -1)[0] ?? "project") || "project", 32); - const suffix = truncateSegment(slugify(id) || "state", 48); + const suffix = truncateSegment(slugify(explicitName?.trim() || id) || "state", 48); const trimmedRunId = runId?.trim(); + if (explicitName && explicitName.trim().length > 0 && !trimmedRunId) return explicitName.trim(); if (!trimmedRunId) { return `spawnfile-${project}-${suffix}-${createShortHash(`${planRoot}:${id}`)}`; } diff --git a/src/compiler/moltnetArtifactTypes.ts b/src/compiler/moltnetArtifactTypes.ts index 8679f687..81522f01 100644 --- a/src/compiler/moltnetArtifactTypes.ts +++ b/src/compiler/moltnetArtifactTypes.ts @@ -12,6 +12,7 @@ export interface MoltnetServerPlan { networkId: string; port?: number; rooms: Array<{ + federation?: "all" | "none" | string[]; id: string; members: string[]; name?: string; @@ -30,6 +31,7 @@ export interface MoltnetNodePlan { credentialSecret?: string; memberId?: string; networkId: string; + receiptStorePath?: string; } export interface MoltnetPersistentMount { diff --git a/src/compiler/moltnetArtifacts.ts b/src/compiler/moltnetArtifacts.ts index 39719be0..6d2b8ef3 100644 --- a/src/compiler/moltnetArtifacts.ts +++ b/src/compiler/moltnetArtifacts.ts @@ -4,6 +4,8 @@ import { SpawnfileError } from "../shared/index.js"; import { createMoltnetCausalDirectory, + createMoltnetDaimonReceiptStorePath, + createMoltnetNetworkStateDirectory, createMoltnetServerConfigPath, createMoltnetOpenTokenDirectory, createMoltnetNativeServerConfig, @@ -55,6 +57,7 @@ export const generateMoltnetArtifacts = async ( const configFiles: EmittedFile[] = []; const persistentMounts = new Map(); const external = createMoltnetExternalParticipantArtifactFiles(plan.moltnetExternalParticipantIntents ?? []); + const receiptStoreOwners = new Map(); configFiles.push(...external.files); const addPersistentMount = (mount: MoltnetPersistentMount): void => { @@ -187,6 +190,25 @@ export const generateMoltnetArtifacts = async ( attachment.network, attachment.memberId ); + const receiptStorePath = agentNode.runtime.name === "daimon" + ? createMoltnetDaimonReceiptStorePath(attachment.network, attachment.memberId) + : undefined; + if (receiptStorePath) { + const owner = `${attachment.network}\u0000${attachment.memberId}`; + const existingOwner = receiptStoreOwners.get(receiptStorePath); + if (existingOwner !== undefined && existingOwner !== owner) { + throw new SpawnfileError("validation_error", "Daimon receipt-store paths collide"); + } + receiptStoreOwners.set(receiptStorePath, owner); + const networkRoot = createMoltnetNetworkStateDirectory(attachment.network); + const covered = [...persistentMounts.values()].some((mount) => + networkRoot === mount.mountPath || networkRoot.startsWith(`${mount.mountPath}/`) + ); + if (!covered) { + const mountId = `moltnet-${attachment.network}-runtime-state`; + addPersistentMount({ id: mountId, mountPath: networkRoot, reason: `Moltnet runtime state for ${attachment.network}`, volumeName: createPersistentVolumeName(plan.root, mountId, undefined, runId) }); + } + } const nodePlanKey = `${attachment.network}::${attachment.memberId}`; if (nodePlanKeys.has(nodePlanKey)) { throw new SpawnfileError( @@ -254,7 +276,8 @@ export const generateMoltnetArtifacts = async ( ...(clientAuth.credentialId ? { credentialId: clientAuth.credentialId } : {}), ...(clientAuth.tokenEnv ? { credentialSecret: clientAuth.tokenEnv } : {}), memberId: attachment.memberId, - networkId: attachment.network + networkId: attachment.network, + ...(receiptStorePath ? { receiptStorePath } : {}) }); } } diff --git a/src/compiler/organizationReadyEvidence.test.ts b/src/compiler/organizationReadyEvidence.test.ts index 2ca5106f..1b0cdbab 100644 --- a/src/compiler/organizationReadyEvidence.test.ts +++ b/src/compiler/organizationReadyEvidence.test.ts @@ -276,6 +276,14 @@ describe("createOrganizationReadinessEvidence", () => { expect(evidence).toMatchObject({ hasExternalMoltnet: false, networks: [], organizationMembers: [], worldBindings: null }); }); + it("preserves a bounded scoped remote Moltnet room member without requiring a local attachment", () => { + const source = input(); + source.moltnetArtifacts!.serverPlans[0]!.rooms[0]!.members.push("remote:peer-agent"); + + expect(createOrganizationReadinessEvidence(source).networks[1]!.rooms[0]!.members) + .toContain("remote:peer-agent"); + }); + it.each([ ["missing node plan", /Moltnet attachment a-net\/alpha has no node plan/u, (value: EvidenceInput) => { expect(value.moltnetArtifacts!.nodePlans).toHaveLength(2); value.moltnetArtifacts!.nodePlans.pop(); expect(value.moltnetArtifacts!.nodePlans).toHaveLength(1); }], ["extra orphan node plan", /Moltnet node a-net\/world has no attachment/u, (value: EvidenceInput) => { expect(value.moltnetArtifacts!.nodePlans).toHaveLength(2); value.moltnetArtifacts!.nodePlans.push({ configPath: configPath("orphan"), memberId: "world", networkId: "a-net" }); value.moltnetArtifacts!.files.push({ content: "{}", path: `container/rootfs${configPath("orphan")}` }); expect(value.moltnetArtifacts!.nodePlans.at(-1)?.memberId).toBe("world"); }], diff --git a/src/compiler/organizationReadyEvidence.ts b/src/compiler/organizationReadyEvidence.ts index 5474fee6..a8f9fa38 100644 --- a/src/compiler/organizationReadyEvidence.ts +++ b/src/compiler/organizationReadyEvidence.ts @@ -30,6 +30,7 @@ export interface OrganizationReadinessEvidence { readonly digest: string; readonly assignments: readonly { readonly memberId: string; readonly nodeId: string }[]; } | null; + readonly daimon?: { readonly receiptPath: string; readonly agents: readonly { readonly agentId: string; readonly engine: string }[] } | null; readonly networks: readonly { readonly id: string; readonly mode: "external" | "managed"; @@ -39,6 +40,7 @@ export interface OrganizationReadinessEvidence { readonly nodeId: string; readonly memberId: string; readonly configPath: string; + readonly receiptPath: string; readonly sha256: string; }[]; }[]; @@ -157,6 +159,17 @@ const assertMemberId = (value: string, label: string): void => { if (!MEMBER_ID.test(value)) fail(`${label} is unbounded or noncanonical`); }; +const assertMoltnetRoomMemberId = (value: string, label: string): void => { + if (MEMBER_ID.test(value)) return; + const separator = value.indexOf(":"); + const networkId = value.slice(0, separator); + const agentId = value.slice(separator + 1); + if (separator <= 0 || separator === value.length - 1 || value.length > 128 + || !ID.test(networkId) || !ID.test(agentId)) { + fail(`${label} is unbounded or noncanonical`); + } +}; + const assertConfigPath = (value: string): void => { if (value.length > 255 || !CONFIG_PATH.test(value)) { fail(`Moltnet config path is noncanonical: ${value}`); @@ -231,13 +244,15 @@ const validateMoltnet = ( const rooms = sort(server.rooms.map((room) => { assertId(room.id, "Moltnet room id"); assertUnique(room.members, (member) => member, `Moltnet room member on ${server.networkId}/${room.id}`); - for (const member of room.members) assertMemberId(member, `Moltnet room member on ${server.networkId}/${room.id}`); + for (const member of room.members) { + assertMoltnetRoomMemberId(member, `Moltnet room member on ${server.networkId}/${room.id}`); + } return { id: room.id, members: sort(room.members, (member) => member) }; }), (room) => room.id); assertUnique(rooms, (room) => room.id, `Moltnet room on ${server.networkId}`); const nodes = sort(nodePlans.filter((node) => node.networkId === server.networkId).map((node) => { const attachment = attachmentByKey.get(`${node.networkId}\u0000${node.memberId}`)!; - return { configPath: node.configPath, memberId: node.memberId!, nodeId: attachment.nodeId, + return { configPath: node.configPath, receiptPath: `/run/spawnfile/moltnet-readiness/${node.networkId}-${node.memberId}.json`, memberId: node.memberId!, nodeId: attachment.nodeId, sha256: sha256(emittedConfigContent(artifacts as MoltnetArtifacts, node.configPath)) }; }), (node) => `${node.memberId}\u0000${node.nodeId}`); return { id: server.networkId, internalPort: server.mode === "managed" ? server.port! : null, @@ -317,9 +332,17 @@ export const createOrganizationReadinessEvidence = ( const projectLabel = normalizeProjectLabelSlug(distribution.organization.project); if (projectLabel.length > 128 || !ID.test(projectLabel)) fail("project label is unbounded or noncanonical"); + const daimonInstance = input.containerArtifacts.report.runtime_instances.find((instance) => instance.runtime === "daimon"); + const daimon = daimonInstance === undefined ? null : { + receiptPath: "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance/runtime-readiness.json", + agents: Object.entries(daimonInstance.engine_by_node_id ?? {}).sort(([left], [right]) => left.localeCompare(right)).map(([agentId, engine]) => ({ agentId, engine })) + }; + if (daimon !== null && daimon.agents.length === 0) fail("Daimon readiness has no compiled agent engines"); + return freeze({ compileFingerprint: fingerprint, compileVersion: input.compileVersion, + daimon, hasExternalMoltnet: networks.some((network) => network.mode === "external"), networks, organizationMembers, diff --git a/src/compiler/runProject.runner.test.ts b/src/compiler/runProject.runner.test.ts index 1c0a628a..2d590e1a 100644 --- a/src/compiler/runProject.runner.test.ts +++ b/src/compiler/runProject.runner.test.ts @@ -99,6 +99,154 @@ describe("runDockerContainer", () => { }); }); + it("rejects a foreign live occupant before run/up attaches an exclusive realm", async () => { + const child = createFakeChild(); + const reservationId = "e".repeat(64); + let reservationLabels: Record = {}; + const { runDockerContainer, spawn } = await loadRunProjectModule( + child, + (_file, args, _options, callback) => { + if (args.includes("create")) { + reservationLabels = Object.fromEntries(args.flatMap((arg, index) => + arg === "--label" ? [args[index + 1]!.split("=", 2) as [string, string]] : [])); + callback(null, { stderr: "", stdout: `${reservationId}\n` }); + return; + } + if (args.includes("inspect")) { + callback(null, { stderr: "", stdout: `${JSON.stringify(reservationId)}\n${JSON.stringify(reservationLabels)}\n` }); + return; + } + if (args.includes("ps")) { + callback(null, { stderr: "", stdout: "foreign-deployment\n" }); + return; + } + if (args.includes("rm")) { + callback(null, { stderr: "", stdout: "" }); + return; + } + callback(new Error("unexpected Docker call"), { stderr: "", stdout: "" }); + } + ); + await expect(runDockerContainer({ + args: ["run", "--rm", "spawnfile-agent"], command: "docker", + containerName: "spawnfile-agent", cwd: "/tmp/spawnfile-run", detach: false, + deploymentName: null, dockerContext: null, envFilePath: "/tmp/spawnfile-run.env", + exclusiveReattachVolumes: ["spawnfile-exclusive-realm-lineage"], + imageTag: "spawnfile-agent", supportDirectory: "/tmp/spawnfile-run" + })).rejects.toThrow(/another running deployment/u); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("serializes concurrent run/up admission until the verified-start boundary releases", async () => { + const child = createFakeChild(); + const reservationId = "f".repeat(64); + let held = false; + let labels: Record = {}; + await loadRunProjectModule(child, (_file, args, _options, callback) => { + if (args.includes("create")) { + if (held) { + callback(new Error("name conflict"), { stderr: "", stdout: "" }); + return; + } + held = true; + labels = Object.fromEntries(args.flatMap((arg, index) => + arg === "--label" ? [args[index + 1]!.split("=", 2) as [string, string]] : [])); + callback(null, { stderr: "", stdout: reservationId }); + return; + } + if (args.includes("inspect")) { + callback(null, { stderr: "", stdout: `${JSON.stringify(reservationId)}\n${JSON.stringify(labels)}\n` }); + return; + } + if (args.includes("ps")) { + callback(null, { stderr: "", stdout: "" }); + return; + } + if (args.includes("rm")) { + held = false; + callback(null, { stderr: "", stdout: "" }); + return; + } + callback(new Error("unexpected Docker call"), { stderr: "", stdout: "" }); + }); + const { withExclusiveVolumeReservations } = await import("./runProjectDockerReservation.js"); + const invocation = { + args: ["run", "-d", "image"], command: "docker", containerName: "candidate", + cwd: "/tmp/spawnfile-run", detach: true, dockerContext: null, + dockerHost: "unix:///var/run/docker.sock", + envFilePath: "/tmp/spawnfile-run.env", exclusiveReattachVolumes: ["exclusive-realm"], + imageTag: "image", supportDirectory: "/tmp/spawnfile-run" + }; + let enter!: () => void; + let finish!: () => void; + const entered = new Promise((resolve) => { enter = resolve; }); + const gate = new Promise((resolve) => { finish = resolve; }); + const first = withExclusiveVolumeReservations(invocation, async () => { + enter(); + await gate; + return { containerId: detachedContainerId }; + }); + await entered; + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .rejects.toThrow(/reservation is already held/u); + finish(); + await expect(first).resolves.toMatchObject({ containerId: detachedContainerId }); + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .resolves.toBeUndefined(); + await expect(withExclusiveVolumeReservations({ + ...invocation, dockerContext: "remote", dockerHost: null + }, async () => undefined)).resolves.toBeUndefined(); + expect(held).toBe(false); + }); + + it("fails closed and releases exact target reservations across provider faults", async () => { + const child = createFakeChild(); + const reservationId = "9".repeat(64); + let labels: Record = {}; + let mode: "invalid-id" | "inspect-invalid" | "ps-error" | "rm-error" = "invalid-id"; + await loadRunProjectModule(child, (_file, args, _options, callback) => { + if (args.includes("create")) { + labels = Object.fromEntries(args.flatMap((arg, index) => + arg === "--label" ? [args[index + 1]!.split("=", 2) as [string, string]] : [])); + callback(null, { stderr: "", stdout: mode === "invalid-id" ? "invalid" : reservationId }); + return; + } + if (args.includes("inspect")) { + callback(null, { stderr: "", stdout: mode === "inspect-invalid" + ? "invalid" + : `${JSON.stringify(reservationId)}\n${JSON.stringify(labels)}\n` }); + return; + } + if (args.includes("ps")) { + callback(mode === "ps-error" ? new Error("provider diagnostic") : null, { stderr: "", stdout: "" }); + return; + } + if (args.includes("rm")) { + callback(mode === "rm-error" ? new Error("provider diagnostic") : null, { stderr: "", stdout: "" }); + return; + } + callback(new Error("unexpected Docker call"), { stderr: "", stdout: "" }); + }); + const { withExclusiveVolumeReservations } = await import("./runProjectDockerReservation.js"); + const invocation = { + args: ["run", "-d", "image"], command: "docker", containerName: "candidate", + cwd: "/tmp/spawnfile-run", detach: true, dockerContext: null, dockerHost: null, + envFilePath: "/tmp/spawnfile-run.env", exclusiveReattachVolumes: ["exclusive-realm"], + imageTag: "image", supportDirectory: "/tmp/spawnfile-run" + }; + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .rejects.toThrow(/returned invalid identity/u); + mode = "inspect-invalid"; + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .rejects.toThrow(/Unable to release exclusive persistent mount reservation/u); + mode = "ps-error"; + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .rejects.toThrow(/Unable to verify exclusive persistent mount occupancy/u); + mode = "rm-error"; + await expect(withExclusiveVolumeReservations(invocation, async () => undefined)) + .rejects.toThrow(/Unable to release exclusive persistent mount reservation/u); + }); + it("captures the immutable image id for detached containers", async () => { const child = createFakeDetachedChild(); const { execFile, runDockerContainer, spawn } = await loadRunProjectModule( diff --git a/src/compiler/runProject.test.ts b/src/compiler/runProject.test.ts index 6d73de50..db169356 100644 --- a/src/compiler/runProject.test.ts +++ b/src/compiler/runProject.test.ts @@ -281,6 +281,8 @@ describe("createDockerRunInvocation", () => { ); expect(invocation.args).toContain("-d"); + expect(invocation.args).toContain("--restart"); + expect(invocation.args).toContain("unless-stopped"); expect(invocation.args).toContain("--name"); expect(invocation.args).toContain("custom-container"); expect(invocation.args).not.toContain("--rm"); @@ -326,506 +328,4 @@ describe("createDockerRunInvocation", () => { await removeDirectory(invocation.supportDirectory); }); - it("merges user env files into the generated Docker env file", async () => { - const envDirectory = await createTempDirectory("spawnfile-run-env-"); - const envFilePath = path.join(envDirectory, ".env"); - await writeUtf8File(envFilePath, "GH_TOKEN=file-gh\nOPTIONAL_FLAG=enabled\n"); - - const invocation = await createDockerRunInvocation( - { - organizationReadinessEvidence: genericOrganizationReadinessEvidence, - outputDirectory: "/tmp/spawnfile-run-out", - report: createCompileReport({ - dockerfile: "Dockerfile", - entrypoint: "entrypoint.sh", - env_example: ".env.example", - model_secrets_required: [], - ports: [], - runtime_instances: [], - runtime_homes: [], - runtime_secrets_required: [], - runtimes_installed: ["picoclaw"], - secrets_required: ["GH_TOKEN"] - }), - reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" - }, - "spawnfile-single-agent", - { envFilePath } - ); - - const envFile = await readUtf8File(invocation.envFilePath); - expect(envFile).toContain("GH_TOKEN=file-gh"); - expect(envFile).toContain("OPTIONAL_FLAG=enabled"); - - await removeDirectory(invocation.supportDirectory); - }); - - it("mounts reported persistent state volumes", async () => { - const invocation = await createDockerRunInvocation( - { - organizationReadinessEvidence: genericOrganizationReadinessEvidence, - outputDirectory: "/tmp/spawnfile-run-out", - report: createCompileReport({ - dockerfile: "Dockerfile", - entrypoint: "entrypoint.sh", - env_example: ".env.example", - model_secrets_required: [], - persistent_mounts: [ - { - id: "moltnet-local-lab-store", - mount_path: "/var/lib/spawnfile/moltnet/networks/local-lab", - reason: "managed Moltnet sqlite store for local-lab", - volume_name: "spawnfile-local-lab-state" - } - ], - ports: [], - runtime_instances: [], - runtime_homes: [], - runtime_secrets_required: [], - runtimes_installed: [], - secrets_required: [] - }), - reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" - }, - "spawnfile-single-agent" - ); - - expect(invocation.args).toContain("-v"); - expect(invocation.args).toContain( - "spawnfile-local-lab-state:/var/lib/spawnfile/moltnet/networks/local-lab" - ); - - await removeDirectory(invocation.supportDirectory); - }); - - it("renders one stable AGY realm volume plus an opaque read-only unlock mount", async () => { - const outputDirectory = await createTempDirectory("spawnfile-agy-run-out-"); - const unlockDirectory = await createTempDirectory("spawnfile-agy-unlock-"); - const unlockPath = path.join(unlockDirectory, "unlock"); - 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: [{ - engine: { kind: "agy" }, id: "agent:agy", - runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" - }], - host: {}, - version: "noopolis.daimon.organization-runtime.v1" - })); - await writeUtf8File(unlockPath, "unlock-canary"); - await (await import("node:fs/promises")).chmod(unlockPath, 0o600); - const prior = process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; - process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = unlockPath; - try { - const report = createCompileReport({ - persistent_mounts: [{ - id: "daimon-agy-subscription-realm", - mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", - reason: "Daimon host AGY subscription realm", - volume_name: "spawnfile-stable-agy-realm" - }], - runtime_instances: [{ - config_path: configPath, - engine_by_node_id: { "agent:agy": "agy" }, - home_path: null, - id: "daimon-organization", - runtime: "daimon" - }], - runtimes_installed: ["daimon"] - }); - const invocation = await createDockerRunInvocation({ - organizationReadinessEvidence: genericOrganizationReadinessEvidence, - outputDirectory, - report, - reportPath: path.join(outputDirectory, "spawnfile-report.json") - }, "spawnfile-agy"); - expect(invocation.args).toContain("spawnfile-stable-agy-realm:/var/lib/spawnfile/daimon/agy-subscription-realm"); - expect(invocation.args).toContain(`${unlockPath}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro`); - expect(invocation.args.join("\n")).not.toContain("unlock-canary"); - expect(await readUtf8File(invocation.envFilePath)).not.toContain("unlock-canary"); - expect(JSON.stringify(report)).not.toContain(unlockPath); - await removeDirectory(invocation.supportDirectory); - } finally { - if (prior === undefined) delete process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; - else process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = prior; - } - }); - - it("fails when required model auth is missing", async () => { - await expect( - createDockerRunInvocation( - { - organizationReadinessEvidence: genericOrganizationReadinessEvidence, - outputDirectory: "/tmp/spawnfile-run-out", - report: createCompileReport({ - dockerfile: "Dockerfile", - entrypoint: "entrypoint.sh", - env_example: ".env.example", - model_secrets_required: ["MISSING_API_KEY"], - ports: [18789], - runtime_instances: [ - { - config_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/openclaw.json", - home_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home", - id: "agent-assistant", - model_auth_methods: { - missing: "api_key" - }, - model_secrets_required: ["MISSING_API_KEY"], - runtime: "openclaw" - } - ], - runtime_homes: [], - runtime_secrets_required: [], - runtimes_installed: ["openclaw"], - secrets_required: ["MISSING_API_KEY"] - }), - reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" - }, - "spawnfile-single-agent" - ) - ).rejects.toMatchObject({ - code: "validation_error", - message: "Missing required runtime env: MISSING_API_KEY" - }); - }); - - it("fails when compile output does not include container metadata", async () => { - await expect( - createDockerRunInvocation( - { - organizationReadinessEvidence: genericOrganizationReadinessEvidence, - outputDirectory: "/tmp/spawnfile-run-out", - report: { - compile_fingerprint: "sf1:test123", - diagnostics: [], - generated_at: "2026-06-11T00:00:00.000Z", - nodes: [], - output_directory: "/tmp/spawnfile-run-out", - root: "/tmp/Spawnfile", - spawnfile_version: "0.1" - }, - reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" - }, - "spawnfile-single-agent" - ) - ).rejects.toMatchObject({ - code: "runtime_error", - message: "Compile output did not include container metadata" - }); - }); -}); - -describe("runProject", () => { - it("compiles the project and runs the built image with auth profile env", async () => { - const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); - process.env.SPAWNFILE_HOME = spawnfileHome; - await setAuthProfileEnv("dev", { - ANTHROPIC_API_KEY: "profile-ant", - SEARCH_API_KEY: "search-key" - }); - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - let capturedInvocationPath = ""; - const runRunner = vi.fn(async (invocation) => { - capturedInvocationPath = invocation.envFilePath; - expect(invocation.command).toBe("docker"); - expect(invocation.args).toContain("--name"); - expect(invocation.args).toContain("spawnfile-single-agent"); - expect(invocation.args).not.toContain("-p"); - expect(await readUtf8File(invocation.envFilePath)).toContain("ANTHROPIC_API_KEY=profile-ant"); - expect(await readUtf8File(invocation.envFilePath)).toContain("SEARCH_API_KEY=search-key"); - }); - - const result = await runProject(path.join(fixturesRoot, "single-agent"), { - authProfile: "dev", - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner - }); - - expect(result.imageTag).toBe("spawnfile-single-agent"); - expect(result.containerName).toBe("spawnfile-single-agent"); - expect(runRunner).toHaveBeenCalledOnce(); - await expect(fileExists(capturedInvocationPath)).resolves.toBe(false); - }, 30000); - - it("uses process env to override stored profile values", async () => { - const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); - process.env.SPAWNFILE_HOME = spawnfileHome; - process.env.ANTHROPIC_API_KEY = "process-ant"; - await setAuthProfileEnv("dev", { - ANTHROPIC_API_KEY: "profile-ant", - SEARCH_API_KEY: "search-key" - }); - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - let result: RunProjectResult | null = null; - - result = await runProject(path.join(fixturesRoot, "single-agent"), { - authProfile: "dev", - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async (invocation) => { - expect(await readUtf8File(invocation.envFilePath)).toContain("ANTHROPIC_API_KEY=process-ant"); - } - }); - - expect(result.authProfileName).toBe("dev"); - }, 30000); - - it("can run with process env only when no auth profile is selected", async () => { - process.env.ANTHROPIC_API_KEY = "process-ant"; - process.env.SEARCH_API_KEY = "search-key"; - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - const result = await runProject(path.join(fixturesRoot, "single-agent"), { - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async (invocation) => { - const envFile = await readUtf8File(invocation.envFilePath); - expect(envFile).toContain("ANTHROPIC_API_KEY=process-ant"); - expect(envFile).toContain("SEARCH_API_KEY=search-key"); - } - }); - - expect(result.authProfileName).toBeNull(); - }, 30000); - - it("generates a run id and stamps it into the compiled entrypoint when the host env didn't provide one", async () => { - delete process.env.NOOPOLIS_RUN_ID; - process.env.ANTHROPIC_API_KEY = "process-ant"; - process.env.SEARCH_API_KEY = "search-key"; - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - await runProject(path.join(fixturesRoot, "single-agent"), { - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async () => undefined - }); - - expect(process.env.NOOPOLIS_RUN_ID).toBeTruthy(); - const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); - expect(entrypoint).toContain(`NOOPOLIS_RUN_ID='${process.env.NOOPOLIS_RUN_ID}'`); - }, 30000); - - it("reuses an already-set NOOPOLIS_RUN_ID instead of generating a new one", async () => { - process.env.NOOPOLIS_RUN_ID = "run-from-host-real"; - process.env.ANTHROPIC_API_KEY = "process-ant"; - process.env.SEARCH_API_KEY = "search-key"; - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - await runProject(path.join(fixturesRoot, "single-agent"), { - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async () => undefined - }); - - const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); - expect(entrypoint).toContain("NOOPOLIS_RUN_ID='run-from-host-real'"); - }, 30000); - - it("removes the generated detached env file after Docker consumes it", async () => { - const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); - process.env.SPAWNFILE_HOME = spawnfileHome; - await setAuthProfileEnv("dev", { - ANTHROPIC_API_KEY: "profile-ant", - SEARCH_API_KEY: "search-key" - }); - - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - let supportDirectory = ""; - - await runProject(path.join(fixturesRoot, "single-agent"), { - authProfile: "dev", - detach: true, - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async (invocation) => { - supportDirectory = invocation.supportDirectory; - expect(invocation.args).toContain("-d"); - expect(invocation.args).not.toContain("--rm"); - expect(await fileExists(invocation.envFilePath)).toBe(true); - }, - targetExecFile: createTargetExecFile() - }); - - expect(await fileExists(path.join(supportDirectory, "run.env"))).toBe(false); - await removeDirectory(supportDirectory); - }, 30000); - - it("writes a deployment record after a detached run succeeds", async () => { - const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); - process.env.SPAWNFILE_HOME = spawnfileHome; - await setAuthProfileEnv("dev", { - ANTHROPIC_API_KEY: "profile-ant", - SEARCH_API_KEY: "search-key" - }); - - const envDirectory = await createTempDirectory("spawnfile-run-env-"); - const envFilePath = path.join(envDirectory, "prod.env"); - await writeUtf8File(envFilePath, "OPTIONAL_FLAG=enabled\n"); - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - - const result = await runProject(path.join(fixturesRoot, "single-agent"), { - authProfile: "dev", - containerArchitecture: "amd64", - deploymentName: "prod-eu", - detach: true, - dockerContext: "hetzner", - envFilePath, - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async (invocation) => { - expect(invocation.args).toContain("com.spawnfile.deployment=prod-eu"); - return { - containerId: "container-123", - imageId: "image-123" - }; - }, - targetExecFile: createTargetExecFile() - }); - - expect(result.deploymentRecordPath).toBe(path.join(outputDirectory, "deployments", "prod-eu.json")); - const record = JSON.parse(await readUtf8File(result.deploymentRecordPath!)) as Record; - expect(record).toMatchObject({ - auth_profile: "dev", - env_file: path.resolve(envFilePath), - manager: "docker", - name: "prod-eu", - target: { - endpoint_fingerprint: expect.stringMatching(/^sha256:[a-f0-9]{32}$/), - kind: "context", - name: "hetzner" - } - }); - expect(record).not.toHaveProperty("envFilePath"); - expect((record.units as Array>)[0]).toMatchObject({ - container_id: "container-123", - container_name: "spawnfile-single-agent", - image_id: "image-123", - image_tag: "spawnfile-single-agent", - kind: "container" - }); - }, 30000); - - it("does not write a deployment record when a detached run fails", async () => { - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - process.env.ANTHROPIC_API_KEY = "process-ant"; - process.env.SEARCH_API_KEY = "search-key"; - - await expect( - runProject(path.join(fixturesRoot, "single-agent"), { - deploymentName: "prod", - detach: true, - imageTag: "spawnfile-single-agent", - outputDirectory, - runRunner: async () => { - throw new SpawnfileError("runtime_error", "docker failed"); - } - }) - ).rejects.toMatchObject({ - code: "runtime_error" - }); - - await expect(fileExists(path.join(outputDirectory, "deployments", "prod.json"))).resolves.toBe(false); - }, 30000); - - it("reuses existing deployment options for detached redeploys", async () => { - const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); - process.env.SPAWNFILE_HOME = spawnfileHome; - await setAuthProfileEnv("dev", { - ANTHROPIC_API_KEY: "profile-ant", - SEARCH_API_KEY: "search-key" - }); - - const envDirectory = await createTempDirectory("spawnfile-run-env-"); - const envFilePath = path.join(envDirectory, "prod.env"); - await writeUtf8File(envFilePath, "SEARCH_API_KEY=file-search\n"); - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - await runProject(path.join(fixturesRoot, "single-agent"), { - authProfile: "dev", - containerArchitecture: "amd64", - deploymentName: "prod", - detach: true, - dockerContext: "hetzner", - envFilePath, - imageTag: "spawnfile-first", - outputDirectory, - runRunner: async () => ({ containerId: "container-1", imageId: "image-1" }), - targetExecFile: createTargetExecFile() - }); - - const secondTargetExecFile = createTargetExecFile(); - await runProject(path.join(fixturesRoot, "single-agent"), { - deploymentName: "prod", - detach: true, - containerArchitecture: "amd64", - outputDirectory, - runRunner: async (invocation) => { - expect(invocation.args.slice(0, 3)).toEqual(["--context", "hetzner", "run"]); - expect(invocation.args).toContain("spawnfile-first"); - expect(invocation.containerName).toBe("spawnfile-first"); - expect(await readUtf8File(invocation.envFilePath)).toContain("SEARCH_API_KEY=file-search"); - return { containerId: "container-2", imageId: "image-2" }; - }, - targetExecFile: secondTargetExecFile - }); - - const record = JSON.parse( - await readUtf8File(path.join(outputDirectory, "deployments", "prod.json")) - ) as Record; - expect((record.units as Array>)[0]).toMatchObject({ - container_id: "container-2", - image_id: "image-2", - image_tag: "spawnfile-first" - }); - }, 30000); - - it("refuses detached redeploys when the recorded docker context endpoint changed", async () => { - const outputDirectory = await createTempDirectory("spawnfile-run-out-"); - await ensureDirectory(path.join(outputDirectory, "deployments")); - await writeUtf8File(path.join(outputDirectory, "deployments", "prod.json"), `${JSON.stringify({ - auth_profile: null, - compile_fingerprint: "sf1:test123", - created_at: "2026-06-11T00:00:00.000Z", - manager: "docker", - name: "prod", - output_directory: outputDirectory, - project_root: "/tmp/project", - target: { - endpoint_fingerprint: "sha256:e86b65e346836167915e2f99413f2db7", - kind: "context", - name: "hetzner" - }, - units: [ - { - container_id: "container-1", - container_name: "spawnfile-first", - contains: [], - id: "prod-container", - image_id: "image-1", - image_tag: "spawnfile-first", - kind: "container", - runtime_instances: [] - } - ], - version: "spawnfile.deployment.v1" - })}\n`); - const runRunner = vi.fn(async () => undefined); - - await expect(runProject(path.join(fixturesRoot, "single-agent"), { - deploymentName: "prod", - detach: true, - outputDirectory, - runRunner, - targetExecFile: async () => ({ stderr: "", stdout: "\"ssh://other@example.com\"\n" }) - })).rejects.toMatchObject({ - code: "runtime_error", - message: expect.stringContaining("endpoint changed") - }); - - expect(runRunner).not.toHaveBeenCalled(); - }, 30000); }); diff --git a/src/compiler/runProject.ts b/src/compiler/runProject.ts index 4505b6b9..c15dd309 100644 --- a/src/compiler/runProject.ts +++ b/src/compiler/runProject.ts @@ -27,7 +27,6 @@ import { } from "../deployment/index.js"; import { DEFAULT_OUTPUT_DIRECTORY, SpawnfileError } from "../shared/index.js"; import { ensureNoopolisRunId, resolveNoopolisRunId } from "../runtime/index.js"; -import { DAIMON_AUTHORIZED_UID_ENV } from "./containerDaimonUidEntrypointRender.js"; import { compileProject, @@ -161,7 +160,7 @@ export const createDockerRunInvocation = async ( : ["run"]; if (options.detach) { - args.push("-d"); + args.push("-d", "--restart", "unless-stopped"); } else { args.push("--rm"); } @@ -184,7 +183,10 @@ export const createDockerRunInvocation = async ( } for (const mount of containerReport.persistent_mounts ?? []) { - args.push("-v", `${mount.volume_name}:${mount.mount_path}`); + args.push( + "--mount", + `type=volume,source=${mount.volume_name},target=${mount.mount_path},volume-nocopy` + ); } const deploymentLabels = options.detach && deploymentName ? (() => { @@ -198,9 +200,6 @@ export const createDockerRunInvocation = async ( if (deploymentLabels) appendDockerLabelArgs(args, deploymentLabels); args.push("--env-file", envFilePath); - if (preparedRuntimeAuth.launchIdentity) { - args.push("--env", `${DAIMON_AUTHORIZED_UID_ENV}=${preparedRuntimeAuth.launchIdentity.uid}`); - } args.push(...(await resolveAuthMountArgs(containerReport, options.authProfile ?? null))); args.push(...preparedRuntimeAuth.mountArgs); args.push(imageTag); @@ -216,6 +215,12 @@ export const createDockerRunInvocation = async ( dockerContext: options.dockerContext ?? null, dockerHost: options.dockerHost ?? null, envFilePath, + ...((containerReport.persistent_mounts ?? []).some((mount) => mount.lifecycle === "exclusive-reattach") ? { + exclusiveReattachVolumes: (containerReport.persistent_mounts ?? []) + .filter((mount) => mount.lifecycle === "exclusive-reattach") + .map((mount) => mount.volume_name) + .sort() + } : {}), imageTag, ...(opaqueDaimonCredentials ? { opaqueDaimonCredentials } : {}), supportDirectory @@ -324,6 +329,7 @@ export const runProject = async ( const compileResult = await compileProject(inputPath, { clean: options.clean, containerArchitecture: targetArchitecture, + deploymentLineage: resolvedOptions.deploymentName ?? "ephemeral", outputDirectory: options.outputDirectory, ...(options.worldBindingsPath !== undefined ? { worldBindingsPath: options.worldBindingsPath } diff --git a/src/compiler/runProjectDeployment.test.ts b/src/compiler/runProjectDeployment.test.ts new file mode 100644 index 00000000..d757d0cb --- /dev/null +++ b/src/compiler/runProjectDeployment.test.ts @@ -0,0 +1,301 @@ +import path from "node:path"; +import os from "node:os"; +import { mkdtemp } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { requireAuthProfile, registerImportedAuth, setAuthProfileEnv } from "../auth/index.js"; +import { + ensureDirectory, + fileExists, + readUtf8File, + removeDirectory, + writeUtf8File +} from "../filesystem/index.js"; +import type { + CompileReport, + ContainerReport, + ContainerRuntimeInstanceReport +} from "../report/index.js"; +import { SpawnfileError } from "../shared/index.js"; +import type { OrganizationReadinessEvidence } from "./organizationReadyEvidence.js"; + +import { + createDockerRunInvocation, + runProject, + type RunProjectResult +} from "./runProject.js"; + +const fixturesRoot = path.resolve(process.cwd(), "examples"); +const temporaryDirectories: string[] = []; +const previousSpawnfileHome = process.env.SPAWNFILE_HOME; +const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; +const previousSearchKey = process.env.SEARCH_API_KEY; +const previousGithubToken = process.env.GH_TOKEN; +const genericOrganizationReadinessEvidence: OrganizationReadinessEvidence = { + compileFingerprint: "sf1:000000000000", compileVersion: "0.1", hasExternalMoltnet: false, + networks: [], organizationMembers: [], projectLabel: "generic", + version: "spawnfile.organization-ready-evidence.v1", worldBindings: null +}; + +const createTempDirectory = async (prefix: string): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +}; + +const createTargetExecFile = () => vi.fn(async () => ({ + stderr: "", + stdout: "\"ssh://deploy@example.com\"\n" +})); + +type RuntimeInstanceInput = Partial + & Pick; +type ContainerReportInput = Omit, "runtime_instances"> & { + runtime_instances?: RuntimeInstanceInput[]; +}; + +const createRuntimeInstanceReport = ( + instance: RuntimeInstanceInput +): ContainerRuntimeInstanceReport => ({ + home_path: null, + internal_port: null, + model_auth_methods: {}, + model_secrets_required: [], + node_ids: [], + published_port: null, + workspace_path: "/var/lib/spawnfile/workspace", + ...instance +}); + +const createContainerReport = (container: ContainerReportInput): ContainerReport => { + const ports = container.ports ?? []; + return { + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + internal_ports: ports, + model_secrets_required: [], + port_mappings: ports.map((port) => ({ internal_port: port, published_port: port })), + ports, + published_ports: ports, + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: [], + secrets_required: [], + ...container, + runtime_instances: (container.runtime_instances ?? []).map(createRuntimeInstanceReport) + }; +}; + +const createCompileReport = (container: ContainerReportInput): CompileReport => ({ + compile_fingerprint: "sf1:test123", + container: createContainerReport(container), + diagnostics: [], + generated_at: "2026-06-11T00:00:00.000Z", + nodes: [], + output_directory: "/tmp/spawnfile-run-out", + root: "/tmp/Spawnfile", + spawnfile_version: "0.1" +}); + +afterEach(async () => { + if (previousSpawnfileHome === undefined) { + delete process.env.SPAWNFILE_HOME; + } else { + process.env.SPAWNFILE_HOME = previousSpawnfileHome; + } + if (previousAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicKey; + } + if (previousSearchKey === undefined) { + delete process.env.SEARCH_API_KEY; + } else { + process.env.SEARCH_API_KEY = previousSearchKey; + } + if (previousGithubToken === undefined) { + delete process.env.GH_TOKEN; + } else { + process.env.GH_TOKEN = previousGithubToken; + } + delete process.env.NOOPOLIS_RUN_ID; + await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); +}); + +describe("runProject", () => { + it("writes a deployment record after a detached run succeeds", async () => { + const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); + process.env.SPAWNFILE_HOME = spawnfileHome; + await setAuthProfileEnv("dev", { + ANTHROPIC_API_KEY: "profile-ant", + SEARCH_API_KEY: "search-key" + }); + + const envDirectory = await createTempDirectory("spawnfile-run-env-"); + const envFilePath = path.join(envDirectory, "prod.env"); + await writeUtf8File(envFilePath, "OPTIONAL_FLAG=enabled\n"); + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + + const result = await runProject(path.join(fixturesRoot, "single-agent"), { + authProfile: "dev", + containerArchitecture: "amd64", + deploymentName: "prod-eu", + detach: true, + dockerContext: "hetzner", + envFilePath, + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async (invocation) => { + expect(invocation.args).toContain("com.spawnfile.deployment=prod-eu"); + return { + containerId: "container-123", + imageId: "image-123" + }; + }, + targetExecFile: createTargetExecFile() + }); + + expect(result.deploymentRecordPath).toBe(path.join(outputDirectory, "deployments", "prod-eu.json")); + const record = JSON.parse(await readUtf8File(result.deploymentRecordPath!)) as Record; + expect(record).toMatchObject({ + auth_profile: "dev", + env_file: path.resolve(envFilePath), + manager: "docker", + name: "prod-eu", + target: { + endpoint_fingerprint: expect.stringMatching(/^sha256:[a-f0-9]{32}$/), + kind: "context", + name: "hetzner" + } + }); + expect(record).not.toHaveProperty("envFilePath"); + expect((record.units as Array>)[0]).toMatchObject({ + container_id: "container-123", + container_name: "spawnfile-single-agent", + image_id: "image-123", + image_tag: "spawnfile-single-agent", + kind: "container" + }); + }, 30000); + + it("does not write a deployment record when a detached run fails", async () => { + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + process.env.ANTHROPIC_API_KEY = "process-ant"; + process.env.SEARCH_API_KEY = "search-key"; + + await expect( + runProject(path.join(fixturesRoot, "single-agent"), { + deploymentName: "prod", + detach: true, + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async () => { + throw new SpawnfileError("runtime_error", "docker failed"); + } + }) + ).rejects.toMatchObject({ + code: "runtime_error" + }); + + await expect(fileExists(path.join(outputDirectory, "deployments", "prod.json"))).resolves.toBe(false); + }, 30000); + + it("reuses existing deployment options for detached redeploys", async () => { + const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); + process.env.SPAWNFILE_HOME = spawnfileHome; + await setAuthProfileEnv("dev", { + ANTHROPIC_API_KEY: "profile-ant", + SEARCH_API_KEY: "search-key" + }); + + const envDirectory = await createTempDirectory("spawnfile-run-env-"); + const envFilePath = path.join(envDirectory, "prod.env"); + await writeUtf8File(envFilePath, "SEARCH_API_KEY=file-search\n"); + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + await runProject(path.join(fixturesRoot, "single-agent"), { + authProfile: "dev", + containerArchitecture: "amd64", + deploymentName: "prod", + detach: true, + dockerContext: "hetzner", + envFilePath, + imageTag: "spawnfile-first", + outputDirectory, + runRunner: async () => ({ containerId: "container-1", imageId: "image-1" }), + targetExecFile: createTargetExecFile() + }); + + const secondTargetExecFile = createTargetExecFile(); + await runProject(path.join(fixturesRoot, "single-agent"), { + deploymentName: "prod", + detach: true, + containerArchitecture: "amd64", + outputDirectory, + runRunner: async (invocation) => { + expect(invocation.args.slice(0, 3)).toEqual(["--context", "hetzner", "run"]); + expect(invocation.args).toContain("spawnfile-first"); + expect(invocation.containerName).toBe("spawnfile-first"); + expect(await readUtf8File(invocation.envFilePath)).toContain("SEARCH_API_KEY=file-search"); + return { containerId: "container-2", imageId: "image-2" }; + }, + targetExecFile: secondTargetExecFile + }); + + const record = JSON.parse( + await readUtf8File(path.join(outputDirectory, "deployments", "prod.json")) + ) as Record; + expect((record.units as Array>)[0]).toMatchObject({ + container_id: "container-2", + image_id: "image-2", + image_tag: "spawnfile-first" + }); + }, 30000); + + it("refuses detached redeploys when the recorded docker context endpoint changed", async () => { + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + await ensureDirectory(path.join(outputDirectory, "deployments")); + await writeUtf8File(path.join(outputDirectory, "deployments", "prod.json"), `${JSON.stringify({ + auth_profile: null, + compile_fingerprint: "sf1:test123", + created_at: "2026-06-11T00:00:00.000Z", + manager: "docker", + name: "prod", + output_directory: outputDirectory, + project_root: "/tmp/project", + target: { + endpoint_fingerprint: "sha256:e86b65e346836167915e2f99413f2db7", + kind: "context", + name: "hetzner" + }, + units: [ + { + container_id: "container-1", + container_name: "spawnfile-first", + contains: [], + id: "prod-container", + image_id: "image-1", + image_tag: "spawnfile-first", + kind: "container", + runtime_instances: [] + } + ], + version: "spawnfile.deployment.v1" + })}\n`); + const runRunner = vi.fn(async () => undefined); + + await expect(runProject(path.join(fixturesRoot, "single-agent"), { + deploymentName: "prod", + detach: true, + outputDirectory, + runRunner, + targetExecFile: async () => ({ stderr: "", stdout: "\"ssh://other@example.com\"\n" }) + })).rejects.toMatchObject({ + code: "runtime_error", + message: expect.stringContaining("endpoint changed") + }); + + expect(runRunner).not.toHaveBeenCalled(); + }, 30000); +}); diff --git a/src/compiler/runProjectDocker.ts b/src/compiler/runProjectDocker.ts index 05fd67ce..dc388c87 100644 --- a/src/compiler/runProjectDocker.ts +++ b/src/compiler/runProjectDocker.ts @@ -7,6 +7,7 @@ import { assertOpaqueDaimonCredentialsHaveNoUserNamespace, pinOpaqueDaimonDockerEndpoint } from "./runProjectDockerDaimonGuards.js"; +import { withExclusiveVolumeReservations } from "./runProjectDockerReservation.js"; const execFile = promisify(execFileCallback); @@ -21,6 +22,8 @@ export interface DockerRunInvocation { dockerContext?: string | null; dockerHost?: string | null; envFilePath: string; + /** Host-stable credential realms that must have at most one live deployment. */ + exclusiveReattachVolumes?: readonly string[]; imageTag: string; onDetachedStarted?: (result: DockerRunResult) => Promise; /** Ephemeral guard; never serialized into reports, records, or labels. */ @@ -332,25 +335,26 @@ const runPreparedDockerContainer = ( }); }); -const runDockerContainerPrepared = ( +const runDockerContainerPrepared = async ( invocation: PinnedOpaqueDaimonInvocation ): Promise => { - if (!invocation.dockerContext || collectBindMountSources(invocation.args).length === 0) { - if (invocation.opaqueDaimonCredentials && !invocation.dockerEndpointPinned) { - return assertOpaqueDaimonCredentialsHaveNoUserNamespace(invocation).then(() => - runPreparedDockerContainer({ - cleanup: async () => undefined, - invocation - }) - ); + const run = async (): Promise => { + if (!invocation.dockerContext || collectBindMountSources(invocation.args).length === 0) { + if (invocation.opaqueDaimonCredentials && !invocation.dockerEndpointPinned) { + await assertOpaqueDaimonCredentialsHaveNoUserNamespace(invocation); + return await runPreparedDockerContainer({ + cleanup: async () => undefined, + invocation + }); + } + return await runPreparedDockerContainer({ + cleanup: async () => undefined, + invocation + }); } - return runPreparedDockerContainer({ - cleanup: async () => undefined, - invocation - }); - } - - return prepareRemoteBindMounts(invocation).then(runPreparedDockerContainer); + return await runPreparedDockerContainer(await prepareRemoteBindMounts(invocation)); + }; + return withExclusiveVolumeReservations(invocation, run); }; export const runDockerContainer: DockerRunRunner = (invocation) => diff --git a/src/compiler/runProjectDockerReservation.ts b/src/compiler/runProjectDockerReservation.ts new file mode 100644 index 00000000..03342fc2 --- /dev/null +++ b/src/compiler/runProjectDockerReservation.ts @@ -0,0 +1,133 @@ +import { createHash, randomUUID } from "node:crypto"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; + +import { SpawnfileError } from "../shared/index.js"; +import type { DockerRunInvocation, DockerRunResult } from "./runProjectDocker.js"; + +const execFile = promisify(execFileCallback); +const dockerId = /^[a-f0-9]{64}$/u; +const versionLabel = "com.spawnfile.exclusive-volume-reservation"; +const ownerLabel = "com.spawnfile.exclusive-volume-owner"; +const volumeLabel = "com.spawnfile.exclusive-volume-digest"; + +interface Reservation { + readonly id: string; + readonly owner: string; + readonly volumeDigest: string; +} + +const endpointArgs = (invocation: DockerRunInvocation): string[] => invocation.dockerContext + ? ["--context", invocation.dockerContext] + : invocation.dockerHost ? ["--host", invocation.dockerHost] : []; + +const execute = async ( + invocation: DockerRunInvocation, + args: readonly string[] +): Promise => { + const { stdout } = await execFile(invocation.command, [...endpointArgs(invocation), ...args], { + cwd: invocation.cwd, + timeout: 10_000 + }); + return stdout; +}; + +const reservationName = (digest: string): string => + `spawnfile-volume-reservation-${digest.slice("sha256:".length, "sha256:".length + 24)}`; + +const verifyReservation = async ( + invocation: DockerRunInvocation, + reservation: Reservation +): Promise => { + try { + const [idRaw, labelsRaw, ...extra] = (await execute(invocation, [ + "container", "inspect", "--format", "{{json .Id}}\n{{json .Config.Labels}}", reservation.id + ])).trim().split("\n"); + if (!idRaw || !labelsRaw || extra.length > 0) throw new Error("shape"); + const id = JSON.parse(idRaw) as unknown; + const labels = JSON.parse(labelsRaw) as unknown; + if (id !== reservation.id || !labels || typeof labels !== "object" || Array.isArray(labels)) throw new Error("identity"); + const values = labels as Record; + if (values[versionLabel] !== "v1" || values[ownerLabel] !== reservation.owner + || values[volumeLabel] !== reservation.volumeDigest) throw new Error("authority"); + } catch { + throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation identity is unavailable"); + } +}; + +const release = async ( + invocation: DockerRunInvocation, + reservations: readonly Reservation[] +): Promise => { + let failed = false; + for (const reservation of [...reservations].reverse()) { + try { + await verifyReservation(invocation, reservation); + await execute(invocation, ["container", "rm", reservation.id]); + } catch { failed = true; } + } + if (failed) throw new SpawnfileError("runtime_error", "Unable to release exclusive persistent mount reservation"); +}; + +const assertAvailable = async (invocation: DockerRunInvocation): Promise => { + for (const volume of invocation.exclusiveReattachVolumes ?? []) { + let stdout: string; + try { + stdout = await execute(invocation, [ + "ps", "--filter", `volume=${volume}`, "--format", "{{.Names}}" + ]); + } catch { + throw new SpawnfileError("runtime_error", "Unable to verify exclusive persistent mount occupancy"); + } + const occupants = stdout.split("\n").map((name) => name.trim()).filter(Boolean); + if (occupants.some((name) => name !== invocation.containerName)) { + throw new SpawnfileError( + "runtime_error", + "Exclusive persistent mount is attached to another running deployment; stop it before reattaching this lineage" + ); + } + } +}; + +const acquire = async (invocation: DockerRunInvocation): Promise => { + const reservations: Reservation[] = []; + const volumes = [...new Set(invocation.exclusiveReattachVolumes ?? [])].sort(); + try { + for (const volume of volumes) { + const volumeDigest = `sha256:${createHash("sha256").update(volume).digest("hex")}`; + const owner = randomUUID(); + let id: string; + try { + id = (await execute(invocation, [ + "container", "create", "--name", reservationName(volumeDigest), + "--label", `${versionLabel}=v1`, + "--label", `${ownerLabel}=${owner}`, + "--label", `${volumeLabel}=${volumeDigest}`, + invocation.imageTag + ])).trim(); + } catch { + throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation is already held"); + } + if (!dockerId.test(id)) throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation returned invalid identity"); + const reservation = { id, owner, volumeDigest }; + reservations.push(reservation); + await verifyReservation(invocation, reservation); + } + await assertAvailable(invocation); + return reservations; + } catch (error) { + if (reservations.length > 0) await release(invocation, reservations); + throw error; + } +}; + +/** Holds daemon-side atomic volume reservations through verified startup. */ +export const withExclusiveVolumeReservations = async ( + invocation: DockerRunInvocation, + operation: () => Promise +): Promise => { + if ((invocation.exclusiveReattachVolumes?.length ?? 0) === 0) return operation(); + const reservations = await acquire(invocation); + try { return await operation(); } + finally { await release(invocation, reservations); } +}; diff --git a/src/compiler/runProjectExecution.test.ts b/src/compiler/runProjectExecution.test.ts new file mode 100644 index 00000000..ffc4d00d --- /dev/null +++ b/src/compiler/runProjectExecution.test.ts @@ -0,0 +1,264 @@ +import path from "node:path"; +import os from "node:os"; +import { mkdtemp } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { requireAuthProfile, registerImportedAuth, setAuthProfileEnv } from "../auth/index.js"; +import { + ensureDirectory, + fileExists, + readUtf8File, + removeDirectory, + writeUtf8File +} from "../filesystem/index.js"; +import type { + CompileReport, + ContainerReport, + ContainerRuntimeInstanceReport +} from "../report/index.js"; +import { SpawnfileError } from "../shared/index.js"; +import type { OrganizationReadinessEvidence } from "./organizationReadyEvidence.js"; + +import { + createDockerRunInvocation, + runProject, + type RunProjectResult +} from "./runProject.js"; + +const fixturesRoot = path.resolve(process.cwd(), "examples"); +const temporaryDirectories: string[] = []; +const previousSpawnfileHome = process.env.SPAWNFILE_HOME; +const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; +const previousSearchKey = process.env.SEARCH_API_KEY; +const previousGithubToken = process.env.GH_TOKEN; +const genericOrganizationReadinessEvidence: OrganizationReadinessEvidence = { + compileFingerprint: "sf1:000000000000", compileVersion: "0.1", hasExternalMoltnet: false, + networks: [], organizationMembers: [], projectLabel: "generic", + version: "spawnfile.organization-ready-evidence.v1", worldBindings: null +}; + +const createTempDirectory = async (prefix: string): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +}; + +const createTargetExecFile = () => vi.fn(async () => ({ + stderr: "", + stdout: "\"ssh://deploy@example.com\"\n" +})); + +type RuntimeInstanceInput = Partial + & Pick; +type ContainerReportInput = Omit, "runtime_instances"> & { + runtime_instances?: RuntimeInstanceInput[]; +}; + +const createRuntimeInstanceReport = ( + instance: RuntimeInstanceInput +): ContainerRuntimeInstanceReport => ({ + home_path: null, + internal_port: null, + model_auth_methods: {}, + model_secrets_required: [], + node_ids: [], + published_port: null, + workspace_path: "/var/lib/spawnfile/workspace", + ...instance +}); + +const createContainerReport = (container: ContainerReportInput): ContainerReport => { + const ports = container.ports ?? []; + return { + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + internal_ports: ports, + model_secrets_required: [], + port_mappings: ports.map((port) => ({ internal_port: port, published_port: port })), + ports, + published_ports: ports, + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: [], + secrets_required: [], + ...container, + runtime_instances: (container.runtime_instances ?? []).map(createRuntimeInstanceReport) + }; +}; + +const createCompileReport = (container: ContainerReportInput): CompileReport => ({ + compile_fingerprint: "sf1:test123", + container: createContainerReport(container), + diagnostics: [], + generated_at: "2026-06-11T00:00:00.000Z", + nodes: [], + output_directory: "/tmp/spawnfile-run-out", + root: "/tmp/Spawnfile", + spawnfile_version: "0.1" +}); + +afterEach(async () => { + if (previousSpawnfileHome === undefined) { + delete process.env.SPAWNFILE_HOME; + } else { + process.env.SPAWNFILE_HOME = previousSpawnfileHome; + } + if (previousAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicKey; + } + if (previousSearchKey === undefined) { + delete process.env.SEARCH_API_KEY; + } else { + process.env.SEARCH_API_KEY = previousSearchKey; + } + if (previousGithubToken === undefined) { + delete process.env.GH_TOKEN; + } else { + process.env.GH_TOKEN = previousGithubToken; + } + delete process.env.NOOPOLIS_RUN_ID; + await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); +}); + +describe("runProject", () => { + it("compiles the project and runs the built image with auth profile env", async () => { + const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); + process.env.SPAWNFILE_HOME = spawnfileHome; + await setAuthProfileEnv("dev", { + ANTHROPIC_API_KEY: "profile-ant", + SEARCH_API_KEY: "search-key" + }); + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + let capturedInvocationPath = ""; + const runRunner = vi.fn(async (invocation) => { + capturedInvocationPath = invocation.envFilePath; + expect(invocation.command).toBe("docker"); + expect(invocation.args).toContain("--name"); + expect(invocation.args).toContain("spawnfile-single-agent"); + expect(invocation.args).not.toContain("-p"); + expect(await readUtf8File(invocation.envFilePath)).toContain("ANTHROPIC_API_KEY=profile-ant"); + expect(await readUtf8File(invocation.envFilePath)).toContain("SEARCH_API_KEY=search-key"); + }); + + const result = await runProject(path.join(fixturesRoot, "single-agent"), { + authProfile: "dev", + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner + }); + + expect(result.imageTag).toBe("spawnfile-single-agent"); + expect(result.containerName).toBe("spawnfile-single-agent"); + expect(runRunner).toHaveBeenCalledOnce(); + await expect(fileExists(capturedInvocationPath)).resolves.toBe(false); + }, 30000); + + it("uses process env to override stored profile values", async () => { + const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); + process.env.SPAWNFILE_HOME = spawnfileHome; + process.env.ANTHROPIC_API_KEY = "process-ant"; + await setAuthProfileEnv("dev", { + ANTHROPIC_API_KEY: "profile-ant", + SEARCH_API_KEY: "search-key" + }); + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + let result: RunProjectResult | null = null; + + result = await runProject(path.join(fixturesRoot, "single-agent"), { + authProfile: "dev", + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async (invocation) => { + expect(await readUtf8File(invocation.envFilePath)).toContain("ANTHROPIC_API_KEY=process-ant"); + } + }); + + expect(result.authProfileName).toBe("dev"); + }, 30000); + + it("can run with process env only when no auth profile is selected", async () => { + process.env.ANTHROPIC_API_KEY = "process-ant"; + process.env.SEARCH_API_KEY = "search-key"; + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + const result = await runProject(path.join(fixturesRoot, "single-agent"), { + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async (invocation) => { + const envFile = await readUtf8File(invocation.envFilePath); + expect(envFile).toContain("ANTHROPIC_API_KEY=process-ant"); + expect(envFile).toContain("SEARCH_API_KEY=search-key"); + } + }); + + expect(result.authProfileName).toBeNull(); + }, 30000); + + it("generates a run id and stamps it into the compiled entrypoint when the host env didn't provide one", async () => { + delete process.env.NOOPOLIS_RUN_ID; + process.env.ANTHROPIC_API_KEY = "process-ant"; + process.env.SEARCH_API_KEY = "search-key"; + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + await runProject(path.join(fixturesRoot, "single-agent"), { + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async () => undefined + }); + + expect(process.env.NOOPOLIS_RUN_ID).toBeTruthy(); + const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); + expect(entrypoint).toContain(`NOOPOLIS_RUN_ID='${process.env.NOOPOLIS_RUN_ID}'`); + }, 30000); + + it("reuses an already-set NOOPOLIS_RUN_ID instead of generating a new one", async () => { + process.env.NOOPOLIS_RUN_ID = "run-from-host-real"; + process.env.ANTHROPIC_API_KEY = "process-ant"; + process.env.SEARCH_API_KEY = "search-key"; + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + await runProject(path.join(fixturesRoot, "single-agent"), { + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async () => undefined + }); + + const entrypoint = await readUtf8File(path.join(outputDirectory, "entrypoint.sh")); + expect(entrypoint).toContain("NOOPOLIS_RUN_ID='run-from-host-real'"); + }, 30000); + + it("removes the generated detached env file after Docker consumes it", async () => { + const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); + process.env.SPAWNFILE_HOME = spawnfileHome; + await setAuthProfileEnv("dev", { + ANTHROPIC_API_KEY: "profile-ant", + SEARCH_API_KEY: "search-key" + }); + + const outputDirectory = await createTempDirectory("spawnfile-run-out-"); + let supportDirectory = ""; + + await runProject(path.join(fixturesRoot, "single-agent"), { + authProfile: "dev", + detach: true, + imageTag: "spawnfile-single-agent", + outputDirectory, + runRunner: async (invocation) => { + supportDirectory = invocation.supportDirectory; + expect(invocation.args).toContain("-d"); + expect(invocation.args).not.toContain("--rm"); + expect(await fileExists(invocation.envFilePath)).toBe(true); + }, + targetExecFile: createTargetExecFile() + }); + + expect(await fileExists(path.join(supportDirectory, "run.env"))).toBe(false); + await removeDirectory(supportDirectory); + }, 30000); +}); diff --git a/src/compiler/runProjectInvocationAdditional.test.ts b/src/compiler/runProjectInvocationAdditional.test.ts new file mode 100644 index 00000000..1f61bb5d --- /dev/null +++ b/src/compiler/runProjectInvocationAdditional.test.ts @@ -0,0 +1,317 @@ +import path from "node:path"; +import os from "node:os"; +import { mkdtemp } from "node:fs/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { requireAuthProfile, registerImportedAuth, setAuthProfileEnv } from "../auth/index.js"; +import { + ensureDirectory, + fileExists, + readUtf8File, + removeDirectory, + writeUtf8File +} from "../filesystem/index.js"; +import type { + CompileReport, + ContainerReport, + ContainerRuntimeInstanceReport +} from "../report/index.js"; +import { SpawnfileError } from "../shared/index.js"; +import type { OrganizationReadinessEvidence } from "./organizationReadyEvidence.js"; + +import { + createDockerRunInvocation, + runProject, + type RunProjectResult +} from "./runProject.js"; + +const fixturesRoot = path.resolve(process.cwd(), "examples"); +const temporaryDirectories: string[] = []; +const previousSpawnfileHome = process.env.SPAWNFILE_HOME; +const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; +const previousSearchKey = process.env.SEARCH_API_KEY; +const previousGithubToken = process.env.GH_TOKEN; +const genericOrganizationReadinessEvidence: OrganizationReadinessEvidence = { + compileFingerprint: "sf1:000000000000", compileVersion: "0.1", hasExternalMoltnet: false, + networks: [], organizationMembers: [], projectLabel: "generic", + version: "spawnfile.organization-ready-evidence.v1", worldBindings: null +}; + +const createTempDirectory = async (prefix: string): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +}; + +const createTargetExecFile = () => vi.fn(async () => ({ + stderr: "", + stdout: "\"ssh://deploy@example.com\"\n" +})); + +type RuntimeInstanceInput = Partial + & Pick; +type ContainerReportInput = Omit, "runtime_instances"> & { + runtime_instances?: RuntimeInstanceInput[]; +}; + +const createRuntimeInstanceReport = ( + instance: RuntimeInstanceInput +): ContainerRuntimeInstanceReport => ({ + home_path: null, + internal_port: null, + model_auth_methods: {}, + model_secrets_required: [], + node_ids: [], + published_port: null, + workspace_path: "/var/lib/spawnfile/workspace", + ...instance +}); + +const createContainerReport = (container: ContainerReportInput): ContainerReport => { + const ports = container.ports ?? []; + return { + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + internal_ports: ports, + model_secrets_required: [], + port_mappings: ports.map((port) => ({ internal_port: port, published_port: port })), + ports, + published_ports: ports, + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: [], + secrets_required: [], + ...container, + runtime_instances: (container.runtime_instances ?? []).map(createRuntimeInstanceReport) + }; +}; + +const createCompileReport = (container: ContainerReportInput): CompileReport => ({ + compile_fingerprint: "sf1:test123", + container: createContainerReport(container), + diagnostics: [], + generated_at: "2026-06-11T00:00:00.000Z", + nodes: [], + output_directory: "/tmp/spawnfile-run-out", + root: "/tmp/Spawnfile", + spawnfile_version: "0.1" +}); + +afterEach(async () => { + if (previousSpawnfileHome === undefined) { + delete process.env.SPAWNFILE_HOME; + } else { + process.env.SPAWNFILE_HOME = previousSpawnfileHome; + } + if (previousAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropicKey; + } + if (previousSearchKey === undefined) { + delete process.env.SEARCH_API_KEY; + } else { + process.env.SEARCH_API_KEY = previousSearchKey; + } + if (previousGithubToken === undefined) { + delete process.env.GH_TOKEN; + } else { + process.env.GH_TOKEN = previousGithubToken; + } + delete process.env.NOOPOLIS_RUN_ID; + await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); +}); + +describe("createDockerRunInvocation", () => { + it("merges user env files into the generated Docker env file", async () => { + const envDirectory = await createTempDirectory("spawnfile-run-env-"); + const envFilePath = path.join(envDirectory, ".env"); + await writeUtf8File(envFilePath, "GH_TOKEN=file-gh\nOPTIONAL_FLAG=enabled\n"); + + const invocation = await createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory: "/tmp/spawnfile-run-out", + report: createCompileReport({ + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + model_secrets_required: [], + ports: [], + runtime_instances: [], + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: ["picoclaw"], + secrets_required: ["GH_TOKEN"] + }), + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-single-agent", + { envFilePath } + ); + + const envFile = await readUtf8File(invocation.envFilePath); + expect(envFile).toContain("GH_TOKEN=file-gh"); + expect(envFile).toContain("OPTIONAL_FLAG=enabled"); + + await removeDirectory(invocation.supportDirectory); + }); + + it("mounts reported persistent state volumes", async () => { + const invocation = await createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory: "/tmp/spawnfile-run-out", + report: createCompileReport({ + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + model_secrets_required: [], + persistent_mounts: [ + { + id: "moltnet-local-lab-store", + mount_path: "/var/lib/spawnfile/moltnet/networks/local-lab", + reason: "managed Moltnet sqlite store for local-lab", + volume_name: "spawnfile-local-lab-state" + } + ], + ports: [], + runtime_instances: [], + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: [], + secrets_required: [] + }), + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-single-agent" + ); + + expect(invocation.args).toContain("--mount"); + expect(invocation.args).toContain( + "type=volume,source=spawnfile-local-lab-state,target=/var/lib/spawnfile/moltnet/networks/local-lab,volume-nocopy" + ); + + await removeDirectory(invocation.supportDirectory); + }); + + it("renders one stable AGY realm volume plus an opaque read-only unlock mount", async () => { + const outputDirectory = await createTempDirectory("spawnfile-agy-run-out-"); + const unlockDirectory = await createTempDirectory("spawnfile-agy-unlock-"); + const unlockPath = path.join(unlockDirectory, "unlock"); + 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: [{ + engine: { kind: "agy" }, id: "agent:agy", + runtimeHomePath: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/agy" + }], + host: {}, + version: "noopolis.daimon.organization-runtime.v1" + })); + await writeUtf8File(unlockPath, "unlock-canary"); + await (await import("node:fs/promises")).chmod(unlockPath, 0o600); + const prior = process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; + process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = unlockPath; + try { + const report = createCompileReport({ + persistent_mounts: [{ + id: "daimon-agy-subscription-realm", + mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "Daimon host AGY subscription realm", + volume_name: "spawnfile-stable-agy-realm" + }], + runtime_instances: [{ + config_path: configPath, + engine_by_node_id: { "agent:agy": "agy" }, + home_path: null, + id: "daimon-organization", + runtime: "daimon" + }], + runtimes_installed: ["daimon"] + }); + const invocation = await createDockerRunInvocation({ + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory, + report, + reportPath: path.join(outputDirectory, "spawnfile-report.json") + }, "spawnfile-agy"); + expect(invocation.args).toContain("type=volume,source=spawnfile-stable-agy-realm,target=/var/lib/spawnfile/daimon/agy-subscription-realm,volume-nocopy"); + expect(invocation.args).toContain(`${unlockPath}:/var/lib/spawnfile/daimon/agy-unlock-secret:ro`); + expect(invocation.args.join("\n")).not.toContain("unlock-canary"); + expect(await readUtf8File(invocation.envFilePath)).not.toContain("unlock-canary"); + expect(JSON.stringify(report)).not.toContain(unlockPath); + await removeDirectory(invocation.supportDirectory); + } finally { + if (prior === undefined) delete process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET; + else process.env.SPAWNFILE_DAIMON_SOURCE_AGY_UNLOCK_SECRET = prior; + } + }); + + it("fails when required model auth is missing", async () => { + await expect( + createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory: "/tmp/spawnfile-run-out", + report: createCompileReport({ + dockerfile: "Dockerfile", + entrypoint: "entrypoint.sh", + env_example: ".env.example", + model_secrets_required: ["MISSING_API_KEY"], + ports: [18789], + runtime_instances: [ + { + config_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home/.openclaw/openclaw.json", + home_path: "/var/lib/spawnfile/instances/openclaw/agent-assistant/home", + id: "agent-assistant", + model_auth_methods: { + missing: "api_key" + }, + model_secrets_required: ["MISSING_API_KEY"], + runtime: "openclaw" + } + ], + runtime_homes: [], + runtime_secrets_required: [], + runtimes_installed: ["openclaw"], + secrets_required: ["MISSING_API_KEY"] + }), + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-single-agent" + ) + ).rejects.toMatchObject({ + code: "validation_error", + message: "Missing required runtime env: MISSING_API_KEY" + }); + }); + + it("fails when compile output does not include container metadata", async () => { + await expect( + createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory: "/tmp/spawnfile-run-out", + report: { + compile_fingerprint: "sf1:test123", + diagnostics: [], + generated_at: "2026-06-11T00:00:00.000Z", + nodes: [], + output_directory: "/tmp/spawnfile-run-out", + root: "/tmp/Spawnfile", + spawnfile_version: "0.1" + }, + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-single-agent" + ) + ).rejects.toMatchObject({ + code: "runtime_error", + message: "Compile output did not include container metadata" + }); + }); +}); diff --git a/src/compiler/runProjectPersistentMounts.test.ts b/src/compiler/runProjectPersistentMounts.test.ts new file mode 100644 index 00000000..298cf5c9 --- /dev/null +++ b/src/compiler/runProjectPersistentMounts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import type { CompileReport } from "../report/index.js"; +import type { OrganizationReadinessEvidence } from "./organizationReadyEvidence.js"; +import { createDockerRunInvocation } from "./runProject.js"; + +const readiness:OrganizationReadinessEvidence={ + compileFingerprint:"sf1:000000000000",compileVersion:"0.1",hasExternalMoltnet:false, + networks:[],organizationMembers:[],projectLabel:"generic", + version:"spawnfile.organization-ready-evidence.v1",worldBindings:null +}; + +const report:CompileReport={ + compile_fingerprint:"sf1:test123",diagnostics:[],generated_at:"2026-08-26T00:00:00.000Z", + nodes:[],output_directory:"/tmp/spawnfile-run-out",root:"/tmp/Spawnfile",spawnfile_version:"0.1", + container:{dockerfile:"Dockerfile",entrypoint:"entrypoint.sh",env_example:".env.example", + internal_ports:[],model_secrets_required:[],persistent_mounts:[{id:"state",mount_path:"/var/lib/spawnfile/state",reason:"state",volume_name:"spawnfile-state"}], + port_mappings:[],ports:[],published_ports:[],runtime_homes:[],runtime_instances:[], + runtime_secrets_required:[],runtimes_installed:[],secrets_required:[]} +}; + +describe("createDockerRunInvocation persistent volume mounts",()=>{ + it("uses volume-nocopy for compiler-declared volumes",async()=>{ + const invocation=await createDockerRunInvocation({organizationReadinessEvidence:readiness,outputDirectory:"/tmp/spawnfile-run-out",report,reportPath:"/tmp/spawnfile-run-out/spawnfile-report.json"},"spawnfile-test"); + expect(invocation.args).toContain("--mount"); + expect(invocation.args).toContain("type=volume,source=spawnfile-state,target=/var/lib/spawnfile/state,volume-nocopy"); + expect(invocation.args).not.toContain("spawnfile-state:/var/lib/spawnfile/state"); + }); +}); diff --git a/src/compiler/syncProjectAuth.test.ts b/src/compiler/syncProjectAuth.test.ts index aa885aca..b8340a54 100644 --- a/src/compiler/syncProjectAuth.test.ts +++ b/src/compiler/syncProjectAuth.test.ts @@ -526,6 +526,7 @@ describe("syncProjectAuth", () => { [ "MOLTNET_ATTACH_TOKEN=bearer-token\n", "REMOTE_NET_PAIR_TOKEN=pair-token\n", + "REMOTE_NET_RELAY_TOKEN=relay-token\n", "MOLTNET_DATABASE_URL=postgres-dsn\n", "MOLTNET_OPEN_STATIC_TOKEN=open-static-token\n" ].join("") @@ -569,7 +570,10 @@ describe("syncProjectAuth", () => { " token_id: attachments", " pairings:", " - id: remote-link", - " remote_base_url: https://remote.example.com", + " relay:", + " url: wss://relay.example.com", + " room: remote-link-v1", + " token_secret: REMOTE_NET_RELAY_TOKEN", " remote_network_id: remote", " remote_network_name: Remote", " token_secret: REMOTE_NET_PAIR_TOKEN", @@ -611,7 +615,8 @@ describe("syncProjectAuth", () => { MOLTNET_ATTACH_TOKEN: "bearer-token", MOLTNET_DATABASE_URL: "postgres-dsn", MOLTNET_OPEN_STATIC_TOKEN: "open-static-token", - REMOTE_NET_PAIR_TOKEN: "pair-token" + REMOTE_NET_PAIR_TOKEN: "pair-token", + REMOTE_NET_RELAY_TOKEN: "relay-token" }); }); diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 3416f9df..26dcbcbe 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -143,6 +143,7 @@ export interface ResolvedAgentSurfaces { } export interface ResolvedTeamNetworkRoom { + federation?: "all" | "none" | string[]; id: string; members: string[]; name?: string; diff --git a/src/compiler/upProject.test.ts b/src/compiler/upProject.test.ts index 5faece4a..6a278b7c 100644 --- a/src/compiler/upProject.test.ts +++ b/src/compiler/upProject.test.ts @@ -205,10 +205,13 @@ describe("upProject", () => { expect(buildProject).toHaveBeenCalledWith("/tmp/project", { buildRunner: expect.any(Function), clean: undefined, + containerArchitecture: undefined, + deploymentLineage: "default", dockerContext: undefined, dockerCommand: undefined, imageTag: "spawnfile-up-container", - outputDirectory: undefined + outputDirectory: undefined, + runtimePackageOverrides: undefined }); expect(createDockerRunInvocation).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/compiler/upProject.ts b/src/compiler/upProject.ts index 046e4152..93f5f27b 100644 --- a/src/compiler/upProject.ts +++ b/src/compiler/upProject.ts @@ -32,6 +32,7 @@ import { DEFAULT_OUTPUT_DIRECTORY, SpawnfileError } from "../shared/index.js"; import { ensureNoopolisRunId, resolveNoopolisRunId } from "../runtime/index.js"; import { fileExists } from "../filesystem/index.js"; import { resolveHostCliCredential } from "./runProjectAuth.js"; +import { defaultDockerTargetExecFile } from "../target/dockerTargetExecFile.js"; import { compileOrganizationHandoff, executeOrganizationHandoff, @@ -127,6 +128,7 @@ export const upProject = async ( containerArchitecture: options.containerArchitecture, dockerContext: resolvedOptions.dockerContext, dockerCommand: options.dockerCommand, + deploymentLineage: resolvedOptions.deploymentName ?? "default", imageTag: resolvedOptions.imageTag, outputDirectory: options.outputDirectory, runtimePackageOverrides: options.runtimePackageOverrides, @@ -275,6 +277,23 @@ export const upProject = async ( execFile: options.targetExecFile, record: pendingRecord }); + if (organizationReady.state === "failed" || organizationReady.state === "cancelled") { + const unit = record.units[0]; + const reference = unit?.container_id ?? unit?.container_name; + if (reference) { + const prefix = invocation.dockerContext + ? ["--context", invocation.dockerContext] + : invocation.dockerHost ? ["--host", invocation.dockerHost] : []; + await (options.targetExecFile ?? defaultDockerTargetExecFile)( + invocation.command, + [...prefix, "container", "rm", "--force", reference], + { timeout: 30_000 } + ); + } + await rm(invocation.supportDirectory, { recursive: true, force: true }); + await rm(deploymentRecordPath, { force: true }); + throw new SpawnfileError("runtime_error", "Detached deployment failed organization readiness and was rolled back"); + } await writeDeploymentRecord(buildResult.outputDirectory, { ...pendingRecord, organization_ready: organizationReady diff --git a/src/compiler/upProjectOrganizationHandoff.test.ts b/src/compiler/upProjectOrganizationHandoff.test.ts index 9d35f053..0c5f2b04 100644 --- a/src/compiler/upProjectOrganizationHandoff.test.ts +++ b/src/compiler/upProjectOrganizationHandoff.test.ts @@ -501,7 +501,7 @@ describe("upProject organization handoff", () => { expect(writeDockerDeploymentRecordForRun).not.toHaveBeenCalled(); }); - it("preserves the same handoff across B35 ready, failed, cancelled, and pending terminal rewrites", async () => { + it("preserves the handoff for ready or pending and removes failed or cancelled candidates", async () => { const outcomes = [ ["organization_ready", "ready"], ["topology_mismatch", "failed"], ["probe_cancelled", "cancelled"], ["probe_unavailable", "pending"] @@ -526,11 +526,16 @@ describe("upProject organization handoff", () => { vi.mocked(probeDockerOrganizationReadiness).mockImplementation(async (input) => ({ ...input.record.organization_ready!, code, state })); - await upProject("/tmp/project", { ...complete, runRunner: async () => runMetadata }); + const operation=upProject("/tmp/project", { ...complete, runRunner: async () => runMetadata }); + if(state==="failed"||state==="cancelled")await expect(operation).rejects.toMatchObject({code:"runtime_error"});else await operation; for (const [, written] of vi.mocked(writeDeploymentRecord).mock.calls) { expect(written.organization_handoff).toEqual(stored.organization_handoff); expect(written.organization_handoff_handle).toEqual(handoffHandle); } + if(state==="failed"||state==="cancelled"){ + expect(targetExecFile).toHaveBeenCalledWith("docker",["container","rm","--force","opaque-container"],{timeout:30_000}); + expect(rm).toHaveBeenCalledWith("/tmp/spawnfile-handoff/deployments/football.json",{force:true}); + } } }); diff --git a/src/compiler/view/types.ts b/src/compiler/view/types.ts index f3784215..b582402e 100644 --- a/src/compiler/view/types.ts +++ b/src/compiler/view/types.ts @@ -13,7 +13,7 @@ export interface OrganizationViewSkillSummary { export interface OrganizationViewResourceSummary { id: string; - kind: "git" | "volume"; + kind: "bundle" | "git" | "volume"; mode: "mutable" | "readonly"; mount: string; sharing: "per_agent" | "team"; diff --git a/src/shared/index.ts b/src/shared/index.ts index bab6f275..15df410e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -2,3 +2,4 @@ export * from "./constants.js"; export * from "./errors.js"; export * from "./redaction.js"; export * from "./types.js"; +export * from "./volumeNames.js"; diff --git a/src/shared/volumeNames.test.ts b/src/shared/volumeNames.test.ts new file mode 100644 index 00000000..7edb083f --- /dev/null +++ b/src/shared/volumeNames.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { createExclusiveReattachVolumeName } from "./volumeNames.js"; + +describe("exclusive reattach volume names", () => { + it("uses a stable generic label when the compiler-owned mount id normalizes empty", () => { + expect(createExclusiveReattachVolumeName("deployment-a", "///")) + .toMatch(/^spawnfile-exclusive-realm-[a-f0-9]{16}$/u); + }); +}); diff --git a/src/shared/volumeNames.ts b/src/shared/volumeNames.ts new file mode 100644 index 00000000..605ee431 --- /dev/null +++ b/src/shared/volumeNames.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; + +/** + * Host-stable name for state that must be reattached, never cloned, across + * run and deployment identities. The mount id is compiler-owned and the + * digest prevents normalization collisions. + */ +export const createExclusiveReattachVolumeName = (lineage: string, mountId: string): string => { + const safe = mountId + .replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 48) || "realm"; + const digest = createHash("sha256").update(lineage, "utf8").update("\0").update(mountId, "utf8").digest("hex").slice(0, 16); + return `spawnfile-exclusive-${safe}-${digest}`; +}; From 8ac511051f625bd788f07877a07405ebfb468f5d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:07 +0200 Subject: [PATCH 08/34] feat(deployment): ship verified no-replace activation --- src/deployment/native/AGENTS.md | 11 +++++ src/deployment/native/CLAUDE.md | 1 + src/deployment/native/Dockerfile | 9 ++++ .../native/artifacts/rename-noreplace-arm64 | Bin 0 -> 600024 bytes .../rename-noreplace-arm64.provenance.json | 1 + .../native/artifacts/rename-noreplace-x64 | Bin 0 -> 707168 bytes .../rename-noreplace-x64.provenance.json | 1 + src/deployment/native/build.mjs | 26 +++++++++++ src/deployment/native/copyArtifacts.mjs | 14 ++++++ src/deployment/native/renameNoreplace.c | 26 +++++++++++ src/deployment/noReplaceActivation.test.ts | 31 +++++++++++++ src/deployment/noReplaceActivation.ts | 42 ++++++++++++++++++ 12 files changed, 162 insertions(+) create mode 100644 src/deployment/native/AGENTS.md create mode 120000 src/deployment/native/CLAUDE.md create mode 100644 src/deployment/native/Dockerfile create mode 100755 src/deployment/native/artifacts/rename-noreplace-arm64 create mode 100644 src/deployment/native/artifacts/rename-noreplace-arm64.provenance.json create mode 100755 src/deployment/native/artifacts/rename-noreplace-x64 create mode 100644 src/deployment/native/artifacts/rename-noreplace-x64.provenance.json create mode 100644 src/deployment/native/build.mjs create mode 100644 src/deployment/native/copyArtifacts.mjs create mode 100644 src/deployment/native/renameNoreplace.c create mode 100644 src/deployment/noReplaceActivation.test.ts create mode 100644 src/deployment/noReplaceActivation.ts diff --git a/src/deployment/native/AGENTS.md b/src/deployment/native/AGENTS.md new file mode 100644 index 00000000..a1250aba --- /dev/null +++ b/src/deployment/native/AGENTS.md @@ -0,0 +1,11 @@ +# Native deployment helpers + +This folder contains bounded, package-owned native helpers for deployment +syscalls Node does not expose. Helpers accept validated single-component names, +emit one bounded JSON result, and fail closed. + +`artifacts/` contains the verified Linux x64 and arm64 package inputs so the +normal Node build stays offline and Docker-free. `copyArtifacts.mjs` verifies +and copies them into `dist`. Only the explicit maintainer `build:native` path +may replace them using the digest-pinned compiler image; CI rebuilds and +syscall-tests both architectures before publication. diff --git a/src/deployment/native/CLAUDE.md b/src/deployment/native/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/deployment/native/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/deployment/native/Dockerfile b/src/deployment/native/Dockerfile new file mode 100644 index 00000000..c228fbdf --- /dev/null +++ b/src/deployment/native/Dockerfile @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +FROM gcc:14.2.0@sha256:b99b86a28812b1e6453a231a947dc43d76fe192788a12f344a9b568bf9f5d24c AS build +COPY renameNoreplace.c /src/renameNoreplace.c +RUN test "$(gcc -dumpfullversion)" = "14.2.0" \ + && gcc -O2 -static -Wall -Wextra -Werror -o /rename-noreplace /src/renameNoreplace.c \ + && strip /rename-noreplace + +FROM scratch +COPY --from=build /rename-noreplace /rename-noreplace diff --git a/src/deployment/native/artifacts/rename-noreplace-arm64 b/src/deployment/native/artifacts/rename-noreplace-arm64 new file mode 100755 index 0000000000000000000000000000000000000000..d1095bec32bdfa97d3ddf85a93860d9d0d10014f GIT binary patch literal 600024 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

6LqaRA-Dud)^W#44;NH(0sDKa4(J67B^^#kh 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%0DDVhbX%6ML{92XO?^ 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

0p?tEi;flZcbI>=DM{D|w14bB9g>ImxU42r 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 z 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 +#include +#include +#include +#include +#include + +static int component(const char *value) { + return value[0] && strcmp(value, ".") && strcmp(value, "..") && !strchr(value, '/'); +} + +int main(int argc, char **argv) { + if (argc != 4 || !component(argv[2]) || !component(argv[3]) || chdir(argv[1]) != 0) { + printf("{\"ok\":false,\"error\":\"INVALID\"}\n"); return 64; + } +#if defined(__linux__) && defined(SYS_renameat2) + if (syscall(SYS_renameat2, AT_FDCWD, argv[2], AT_FDCWD, argv[3], 1) == 0) { + printf("{\"ok\":true}\n"); return 0; + } + const char *code = errno == EEXIST ? "EEXIST" : errno == EXDEV ? "EXDEV" : errno == ENOSYS ? "ENOSYS" : errno == EOPNOTSUPP ? "EOPNOTSUPP" : "SYSCALL"; + printf("{\"ok\":false,\"error\":\"%s\",\"errno\":%d}\n", code, errno); return 1; +#else + printf("{\"ok\":false,\"error\":\"ENOSYS\"}\n"); return 1; +#endif +} diff --git a/src/deployment/noReplaceActivation.test.ts b/src/deployment/noReplaceActivation.test.ts new file mode 100644 index 00000000..8a11c432 --- /dev/null +++ b/src/deployment/noReplaceActivation.test.ts @@ -0,0 +1,31 @@ +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { activateNoReplaceWith } from "./noReplaceActivation.js"; + +const source = path.join(path.sep, "volumes", "target.migration-1"); +const destination = path.join(path.sep, "volumes", "target"); + +describe("atomic no-replace activation helper", () => { + it("uses one absolute package helper with bounded execution", async () => { + const run = vi.fn(async (_file: string, _args: string[], _options: { encoding: "utf8"; maxBuffer: number; timeout: number; windowsHide: true }) => ({ stderr: "", stdout: '{"ok":true}\n' })); + await activateNoReplaceWith(source, destination, { arch: "x64", platform: "linux", run }); + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[0]).toMatch(/^\/.*\/native\/rename-noreplace-x64$/u); + expect(run.mock.calls[0]?.[1]).toEqual([path.join(path.sep, "volumes"), "target.migration-1", "target"]); + expect(run.mock.calls[0]?.[2]).toMatchObject({ maxBuffer: 4096, timeout: 5000 }); + }); + + it.each(["EEXIST", "EXDEV", "ENOSYS", "EOPNOTSUPP"])("maps %s without fallback", async (code) => { + const failure = Object.assign(new Error("helper failed"), { stdout: `${JSON.stringify({ ok: false, error: code })}\n` }); + await expect(activateNoReplaceWith(source, destination, { arch: "arm64", platform: "linux", run: async () => { throw failure; } })).rejects.toMatchObject({ code }); + }); + + it("fails closed for missing, timeout, malformed output, and unsupported hosts", async () => { + await expect(activateNoReplaceWith(source, destination, { arch: "x64", platform: "linux", run: async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); } })).rejects.toThrow(/helper is missing/u); + await expect(activateNoReplaceWith(source, destination, { arch: "x64", platform: "linux", run: async () => { throw Object.assign(new Error("timeout"), { killed: true }); } })).rejects.toThrow(/timed out/u); + await expect(activateNoReplaceWith(source, destination, { arch: "x64", platform: "linux", run: async () => ({ stderr: "", stdout: "garbage\n" }) })).rejects.toThrow(/malformed output/u); + await expect(activateNoReplaceWith(source, destination, { arch: "x64", platform: "darwin", run: async () => ({ stderr: "", stdout: "" }) })).rejects.toThrow(/unsupported/u); + }); +}); diff --git a/src/deployment/noReplaceActivation.ts b/src/deployment/noReplaceActivation.ts new file mode 100644 index 00000000..edae56e1 --- /dev/null +++ b/src/deployment/noReplaceActivation.ts @@ -0,0 +1,42 @@ +import { execFile as execFileCallback } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); +const MAX_OUTPUT = 4096; +const TIMEOUT_MS = 5_000; +type HelperResult = { stderr: string; stdout: string }; +type HelperRun = (file: string, args: string[], options: { encoding: "utf8"; maxBuffer: number; timeout: number; windowsHide: true }) => Promise; + +const component = (value: string): boolean => value.length > 0 && value !== "." && value !== ".." && !value.includes(path.sep); + +export const activateNoReplaceWith = async (temporaryPath: string, destinationPath: string, runtime: { arch: string; platform: string; run: HelperRun }): Promise => { + if (runtime.platform !== "linux" || (runtime.arch !== "x64" && runtime.arch !== "arm64")) throw new Error("Workspace resource atomic no-replace activation is unsupported on this platform"); + const parent = path.dirname(temporaryPath); + if (parent !== path.dirname(destinationPath) || !path.isAbsolute(parent)) throw new Error("Workspace resource activation paths must share one canonical parent"); + const source = path.basename(temporaryPath); const destination = path.basename(destinationPath); + if (!component(source) || !component(destination)) throw new Error("Workspace resource activation names must be single components"); + const helper = fileURLToPath(new URL(`./native/rename-noreplace-${runtime.arch}`, import.meta.url)); + try { + const result = await runtime.run(helper, [parent, source, destination], { encoding: "utf8", maxBuffer: MAX_OUTPUT, timeout: TIMEOUT_MS, windowsHide: true }); + if (result.stderr !== "" || result.stdout !== '{"ok":true}\n') throw new Error("Workspace resource atomic helper returned malformed output"); + } catch (error) { + const failure = error as NodeJS.ErrnoException & { stdout?: string; killed?: boolean }; + if (failure.killed || failure.code === "ETIMEDOUT") throw new Error("Workspace resource atomic helper timed out"); + if (failure.code === "ENOENT") throw new Error("Workspace resource atomic helper is missing"); + if (typeof failure.stdout === "string" && failure.stdout.length <= MAX_OUTPUT) { + try { + const parsed = JSON.parse(failure.stdout) as { error?: string; ok?: boolean }; + if (parsed.ok === false && ["EEXIST", "EXDEV", "ENOSYS", "EOPNOTSUPP"].includes(parsed.error ?? "")) { + const mapped = new Error(`Workspace resource atomic activation failed: ${parsed.error}`) as NodeJS.ErrnoException; mapped.code = parsed.error; throw mapped; + } + } catch (parsedError) { if ((parsedError as NodeJS.ErrnoException).code) throw parsedError; } + } + if (failure.message.includes("malformed output")) throw failure; + throw new Error("Workspace resource atomic helper failed closed"); + } +}; + +export const activateNoReplace = async (temporaryPath: string, destinationPath: string): Promise => + await activateNoReplaceWith(temporaryPath, destinationPath, { arch: process.arch, platform: process.platform, run: execFile }); From 2e3c5247b61d1c5380cb5f232f8e27a6bd3dd5cd Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:13 +0200 Subject: [PATCH 09/34] feat(deployment): migrate persistent runtime state --- .../product-state-volume-integration.test.mjs | 48 +++ src/cli/productStateCloneCommand.test.ts | 21 ++ src/cli/productStateCloneCommand.ts | 20 ++ .../workspaceResourceMigrationCommand.test.ts | 38 +++ src/cli/workspaceResourceMigrationCommand.ts | 42 +++ src/deployment/productStateClone.test.ts | 81 +++++ src/deployment/productStateClone.ts | 150 +++++++++ .../workspaceResourceMigration.test.ts | 155 +++++++++ src/deployment/workspaceResourceMigration.ts | 301 ++++++++++++++++++ 9 files changed, 856 insertions(+) create mode 100644 scripts/product-state-volume-integration.test.mjs create mode 100644 src/cli/productStateCloneCommand.test.ts create mode 100644 src/cli/productStateCloneCommand.ts create mode 100644 src/cli/workspaceResourceMigrationCommand.test.ts create mode 100644 src/cli/workspaceResourceMigrationCommand.ts create mode 100644 src/deployment/productStateClone.test.ts create mode 100644 src/deployment/productStateClone.ts create mode 100644 src/deployment/workspaceResourceMigration.test.ts create mode 100644 src/deployment/workspaceResourceMigration.ts diff --git a/scripts/product-state-volume-integration.test.mjs b/scripts/product-state-volume-integration.test.mjs new file mode 100644 index 00000000..277d1a8e --- /dev/null +++ b/scripts/product-state-volume-integration.test.mjs @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = process.cwd(); +const source = mkdtempSync(path.join(os.tmpdir(), "spawnfile-product-volume-")); +const volume = `spawnfile-product-preseed-${process.pid}`; +const rollbackVolume = `${volume}-rollback`; +const cleanupVolume = `${volume}-cleanup`; +const container = `spawnfile-product-preseed-${process.pid}`; +const run = (args) => execFileSync("docker", args, { encoding: "utf8", stdio: "pipe" }); +const runCliAuthorityClone = () => { + if (process.platform !== "linux") return; + const sourceVolume = `${volume}-authority-source`, candidateVolume = `${volume}-authority-candidate`, sourceContainer = `${container}-authority`, authority = path.join(source, "authority.json"), proof = path.join(source, "proof.json"), authorityRequest = path.join(source, "authority-request.json"), cloneRequest = path.join(source, "clone-request.json"), cloneReceipt = path.join(source, "clone-receipt.json"); + try { + run(["volume", "create", sourceVolume]); run(["volume", "create", candidateVolume]); + run(["run", "-d", "--name", sourceContainer, "--label", "com.spawnfile.run_id=live-cli", "-v", `${sourceVolume}:/product`, "alpine:3.22", "sleep", "300"]); + run(["exec", sourceContainer, "sh", "-c", "printf cli-edition >/product/edition.json"]); run(["run", "--rm", "-v", `${candidateVolume}:/candidate`, "alpine:3.22", "chmod", "0777", "/candidate"]); + const destination = JSON.parse(run(["volume", "inspect", candidateVolume]))[0].Mountpoint, startedAt = JSON.parse(run(["inspect", sourceContainer]))[0].State.StartedAt; + writeFileSync(authorityRequest, `${JSON.stringify({ version: "spawnfile.product-state-source-authority-request.v1", docker_command: "docker", container: sourceContainer, source_run_id: "live-cli", mount_path: "/product", candidate_volume_name: candidateVolume, candidate_resource_identity: `sha256:${"d".repeat(64)}`, receipt_path: authority, proof_path: proof })}\n`); + execFileSync("node", ["dist/cli/index.js", "product-state", "authority", authorityRequest], { cwd: root, stdio: "pipe" }); + const after = JSON.parse(run(["inspect", sourceContainer]))[0]; if (after.State.Paused || after.State.StartedAt !== startedAt) throw new Error("authority did not restore the exact running source"); + writeFileSync(cloneRequest, `${JSON.stringify({ version: "spawnfile.product-state-clone-request.v1", authority_receipt_path: authority, docker_command: "docker", destination, proof_path: proof, receipt_path: cloneReceipt, candidate_run_id: "candidate-cli" })}\n`); + execFileSync("node", ["dist/cli/index.js", "product-state", "clone", cloneRequest], { cwd: root, stdio: "pipe" }); + run(["run", "--rm", "-v", `${candidateVolume}:/candidate:ro`, "alpine:3.22", "sh", "-eu", "-c", "test \"$(cat /candidate/edition.json)\" = cli-edition; test -s /candidate/.spawnfile-resource-identity"]); + } finally { try { run(["rm", "-f", sourceContainer]); } catch {} try { run(["volume", "rm", "-f", sourceVolume]); } catch {} try { run(["volume", "rm", "-f", candidateVolume]); } catch {} } +}; +try { + writeFileSync(path.join(source, "edition.json"), "edition"); + mkdirSync(path.join(source, "nested")); writeFileSync(path.join(source, "nested", "index.json"), "index"); + run(["volume", "create", volume]); run(["volume", "create", rollbackVolume]); run(["volume", "create", cleanupVolume]); + const program = `import{createHash}from"node:crypto";import{mkdir,writeFile}from"node:fs/promises";import{cloneQuiescedProductState}from"/app/dist/deployment/productStateClone.js";const h=x=>"sha256:"+createHash("sha256").update(x).digest("hex"),proof={version:"spawnfile.product-state-quiescence.v1",state:"quiesced",source_run_id:"live",files:[{path:"edition.json",sha256:h("edition")},{path:"nested/index.json",sha256:h("index")}]};await cloneQuiescedProductState({source:"/source",destination:"/dest",proof,candidateRunId:"candidate"});await mkdir("/rollback/.spawnfile-preseed-crash");await writeFile("/rollback/edition.json","partial");await writeFile("/rollback/.spawnfile-product-state-preseed-journal",JSON.stringify({version:"spawnfile.product-state-preseed-journal.v1",candidate_run_id:"recovery",entries:["edition.json"],staging:".spawnfile-preseed-crash"}));await cloneQuiescedProductState({source:"/source",destination:"/rollback",proof,candidateRunId:"recovery"});let failed=false;try{await cloneQuiescedProductState({source:"/source",destination:"/cleanup",proof:{...proof,files:[{...proof.files[0],sha256:"sha256:"+"0".repeat(64)}]},candidateRunId:"cleanup"})}catch{failed=true}if(!failed)process.exit(2);`; + run(["create", "--name", container, "-v", `${volume}:/dest`, "-v", `${rollbackVolume}:/rollback`, "-v", `${cleanupVolume}:/cleanup`, "node:22-bookworm-slim", "sleep", "300"]); run(["start", container]); run(["exec", container, "mkdir", "-p", "/app"]); + run(["cp", path.join(root, "dist"), `${container}:/app/dist`]); run(["cp", path.join(root, "node_modules"), `${container}:/app/node_modules`]); run(["cp", source, `${container}:/source`]); + run(["exec", "-w", "/app", container, "node", "--input-type=module", "-e", program]); + run(["run", "--rm", "-v", `${volume}:/dest:ro`, "alpine:3.22", "sh", "-eu", "-c", "test \"$(cat /dest/edition.json)\" = edition; test \"$(cat /dest/nested/index.json)\" = index; test \"$(find /dest -type f | wc -l)\" -eq 2"]); + run(["run", "--rm", "-v", `${rollbackVolume}:/rollback:ro`, "alpine:3.22", "sh", "-eu", "-c", "test \"$(cat /rollback/edition.json)\" = edition; test \"$(cat /rollback/nested/index.json)\" = index; test ! -e /rollback/.spawnfile-product-state-preseed-journal"]); + run(["run", "--rm", "-v", `${cleanupVolume}:/cleanup:ro`, "alpine:3.22", "sh", "-eu", "-c", "test \"$(find /cleanup -mindepth 1 -maxdepth 1 | wc -l)\" -eq 0"]); + runCliAuthorityClone(); + console.log("PASS product-state named-volume preseed"); +} finally { + try { run(["rm", "-f", container]); } catch {} + try { run(["volume", "rm", "-f", volume]); } catch {} + try { run(["volume", "rm", "-f", rollbackVolume]); } catch {} + try { run(["volume", "rm", "-f", cleanupVolume]); } catch {} + rmSync(source, { recursive: true, force: true }); +} diff --git a/src/cli/productStateCloneCommand.test.ts b/src/cli/productStateCloneCommand.test.ts new file mode 100644 index 00000000..443329df --- /dev/null +++ b/src/cli/productStateCloneCommand.test.ts @@ -0,0 +1,21 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { clone } = vi.hoisted(() => ({ clone: vi.fn(async () => ({ version: "spawnfile.product-state-clone-receipt.v1" })) })); +vi.mock("../deployment/productStateClone.js", () => ({ issueProductStateSourceSnapshot: vi.fn(), runProductStateCloneWorkflow: clone })); +import { isProductStateCloneInvocation, runProductStateCloneCommand } from "./productStateCloneCommand.js"; + +afterEach(() => vi.restoreAllMocks()); +describe("product-state clone CLI", () => { + it("routes one strict request and fails closed on malformed invocation", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-clone-cli-")); try { + const request = path.join(root, "request.json"); await writeFile(request, JSON.stringify({ version: "spawnfile.product-state-clone-request.v1", authority_receipt_path: "/authority", docker_command: "docker", destination: "/candidate", proof_path: "/proof", receipt_path: "/receipt", candidate_run_id: "candidate" })); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + expect(isProductStateCloneInvocation(["product-state", "clone", request])).toBe(true); expect(isProductStateCloneInvocation(["compile"])).toBe(false); + await expect(runProductStateCloneCommand(["product-state", "clone", request])).resolves.toBe(0); expect(clone).toHaveBeenCalledWith({ authorityReceiptPath: "/authority", dockerCommand: "docker", destination: "/candidate", proofPath: "/proof", receiptPath: "/receipt", candidateRunId: "candidate" }); expect(stdout).toHaveBeenCalled(); + await expect(runProductStateCloneCommand(["product-state", "clone"])).resolves.toBe(1); expect(stderr).toHaveBeenCalledWith("error: Product-state clone failed\n"); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/src/cli/productStateCloneCommand.ts b/src/cli/productStateCloneCommand.ts new file mode 100644 index 00000000..17f971eb --- /dev/null +++ b/src/cli/productStateCloneCommand.ts @@ -0,0 +1,20 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { issueProductStateSourceSnapshot, runProductStateCloneWorkflow } from "../deployment/productStateClone.js"; + +const requestSchema = z.object({ version: z.literal("spawnfile.product-state-clone-request.v1"), authority_receipt_path: z.string().min(1).max(4096), docker_command: z.string().min(1).max(4096), destination: z.string().min(1).max(4096), proof_path: z.string().min(1).max(4096), receipt_path: z.string().min(1).max(4096), candidate_run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u) }).strict(); +const authorityRequestSchema = z.object({ version: z.literal("spawnfile.product-state-source-authority-request.v1"), docker_command: z.string().min(1).max(4096), container: z.string().min(1).max(255), source_run_id: z.string().min(1).max(128), mount_path: z.string().min(1).max(4096), candidate_volume_name: z.string().min(1).max(255), candidate_resource_identity: z.string().regex(/^sha256:[a-f0-9]{64}$/u), receipt_path: z.string().min(1).max(4096), proof_path: z.string().min(1).max(4096) }).strict(); +export const isProductStateCloneInvocation = (argv: readonly string[]): boolean => argv[0] === "product-state" && ["authority", "clone"].includes(argv[1] ?? ""); +export const runProductStateCloneCommand = async (argv: readonly string[]): Promise => { + try { + if (argv.length !== 3) throw new Error("usage"); + const bytes = await readFile(argv[2]!); if (bytes.length > 65_536) throw new Error("oversized"); + if (argv[1] === "authority") { + const request = authorityRequestSchema.parse(JSON.parse(bytes.toString("utf8"))), snapshot = await issueProductStateSourceSnapshot({ dockerCommand: request.docker_command, container: request.container, sourceRunId: request.source_run_id, mountPath: request.mount_path, candidateVolumeName: request.candidate_volume_name, candidateResourceIdentity: request.candidate_resource_identity }); + await mkdir(path.dirname(request.receipt_path), { recursive: true, mode: 0o700 }); await writeFile(request.proof_path, `${JSON.stringify(snapshot.proof)}\n`, { flag: "wx", mode: 0o600 }); await writeFile(request.receipt_path, `${JSON.stringify(snapshot.authority)}\n`, { flag: "wx", mode: 0o600 }); process.stdout.write(`${JSON.stringify(snapshot.authority)}\n`); return 0; + } + const request = requestSchema.parse(JSON.parse(bytes.toString("utf8"))); + process.stdout.write(`${JSON.stringify(await runProductStateCloneWorkflow({ authorityReceiptPath: request.authority_receipt_path, dockerCommand: request.docker_command, destination: request.destination, proofPath: request.proof_path, receiptPath: request.receipt_path, candidateRunId: request.candidate_run_id }))}\n`); return 0; + } catch { process.stderr.write("error: Product-state clone failed\n"); return 1; } +}; diff --git a/src/cli/workspaceResourceMigrationCommand.test.ts b/src/cli/workspaceResourceMigrationCommand.test.ts new file mode 100644 index 00000000..2c0141a4 --- /dev/null +++ b/src/cli/workspaceResourceMigrationCommand.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runWorkspaceResourceMigrationCommand } from "./workspaceResourceMigrationCommand.js"; + +const receipt = (status: "activated" | "rolled_back") => ({ + version: "spawnfile.workspace-resource-migration.v1" as const, + active_path: status === "activated" ? "/volume/r28" : "/source/r28", + destination_path: "/volume/r28", + manifest_sha256: `sha256:${"a".repeat(64)}` as const, + rollback: status === "rolled_back", + source_path: "/source/r28", + source_retained: true as const, + status +}); +const identity = `sha256:${"b".repeat(64)}`; +const args = ["workspace-resource", "migrate", "/source/r28", "/volume/r28", "--manifest", "/manifest.json", "--resolved-identity", identity, "--source-quiesced", "--json"]; + +describe("workspace resource migration command", () => { + it("emits an activated machine receipt", async () => { + const stdout = vi.fn(); const migrate = vi.fn(async () => receipt("activated")); + await expect(runWorkspaceResourceMigrationCommand(args, { migrate, stdout })).resolves.toBe(0); + expect(migrate).toHaveBeenCalledWith({ destinationPath: "/volume/r28", manifestPath: "/manifest.json", resolvedIdentity: identity, sourcePath: "/source/r28", sourceQuiesced: true }); + expect(JSON.parse(stdout.mock.calls[0]![0])).toMatchObject({ source_retained: true, status: "activated" }); + }); + + it("returns failure while exposing a recorded rollback", async () => { + const stdout = vi.fn(); + await expect(runWorkspaceResourceMigrationCommand(args, { migrate: async () => receipt("rolled_back"), stdout })).resolves.toBe(1); + expect(JSON.parse(stdout.mock.calls[0]![0])).toMatchObject({ active_path: "/source/r28", rollback: true, status: "rolled_back" }); + }); + + it("fails closed on malformed arguments and migration errors", async () => { + const stderr = vi.fn(); + await expect(runWorkspaceResourceMigrationCommand(["workspace-resource", "migrate"], { stderr })).resolves.toBe(2); + await expect(runWorkspaceResourceMigrationCommand(args, { migrate: async () => { throw new Error("preflight failed"); }, stderr })).resolves.toBe(1); + expect(stderr).toHaveBeenLastCalledWith("preflight failed"); + }); +}); diff --git a/src/cli/workspaceResourceMigrationCommand.ts b/src/cli/workspaceResourceMigrationCommand.ts new file mode 100644 index 00000000..772567b0 --- /dev/null +++ b/src/cli/workspaceResourceMigrationCommand.ts @@ -0,0 +1,42 @@ +import { migrateWorkspaceResource, type WorkspaceResourceMigrationReceipt } from "../deployment/workspaceResourceMigration.js"; + +export const isWorkspaceResourceMigrationInvocation = (argv: readonly string[]): boolean => + argv[0] === "workspace-resource" && argv[1] === "migrate"; + +export interface WorkspaceResourceMigrationCommandDependencies { + migrate?: typeof migrateWorkspaceResource; + stderr?: (message: string) => void; + stdout?: (message: string) => void; +} + +const usage = "usage: spawnfile workspace-resource migrate --manifest --resolved-identity --source-quiesced [--json]"; + +export const runWorkspaceResourceMigrationCommand = async ( + argv: readonly string[], + dependencies: WorkspaceResourceMigrationCommandDependencies = {} +): Promise => { + const stdout = dependencies.stdout ?? ((message) => process.stdout.write(`${message}\n`)); + const stderr = dependencies.stderr ?? ((message) => process.stderr.write(`${message}\n`)); + if (!isWorkspaceResourceMigrationInvocation(argv)) return 2; + const sourcePath = argv[2]; const destinationPath = argv[3]; + const manifestIndex = argv.indexOf("--manifest"); const manifestPath = manifestIndex < 0 ? undefined : argv[manifestIndex + 1]; + const identityIndex = argv.indexOf("--resolved-identity"); const resolvedIdentity = identityIndex < 0 ? undefined : argv[identityIndex + 1]; + const quiescedIndex = argv.indexOf("--source-quiesced"); + const allowed = new Set([0, 1, 2, 3, manifestIndex, manifestIndex + 1, identityIndex, identityIndex + 1, quiescedIndex, argv.indexOf("--json")].filter((index) => index >= 0)); + if (!sourcePath || !destinationPath || !manifestPath || !resolvedIdentity || quiescedIndex < 0 || argv.some((_value, index) => !allowed.has(index))) { stderr(usage); return 2; } + try { + const receipt = await (dependencies.migrate ?? migrateWorkspaceResource)({ destinationPath, manifestPath, resolvedIdentity, sourcePath, sourceQuiesced: true }); + stdout(argv.includes("--json") ? JSON.stringify(receipt) : render(receipt)); + return receipt.status === "activated" ? 0 : 1; + } catch (error) { + stderr(error instanceof Error ? error.message : String(error)); + return 1; + } +}; + +const render = (receipt: WorkspaceResourceMigrationReceipt): string => [ + `workspace resource migration: ${receipt.status}`, + `active: ${receipt.active_path}`, + `source retained: ${receipt.source_retained ? "yes" : "no"}`, + `rollback: ${receipt.rollback ? "yes" : "no"}` +].join("\n"); diff --git a/src/deployment/productStateClone.test.ts b/src/deployment/productStateClone.test.ts new file mode 100644 index 00000000..33f00f03 --- /dev/null +++ b/src/deployment/productStateClone.test.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { cloneQuiescedProductState, createProductStateProof, issueProductStateSourceAuthority, issueProductStateSourceSnapshot, runProductStateCloneWorkflow } from "./productStateClone.js"; + +const sha = (value: string): string => `sha256:${createHash("sha256").update(value).digest("hex")}`; +describe("classified product-state clone", () => { + it("copies only checksummed quiesced product files into a fresh namespace", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-state-clone-")); try { + const source = path.join(root, "source"), destination = path.join(root, "candidate"); await import("node:fs/promises").then(({ mkdir }) => mkdir(source)); + await writeFile(path.join(source, "edition.json"), "edition"); await writeFile(path.join(source, "token.json"), "secret"); + const proof = { version: "spawnfile.product-state-quiescence.v1", state: "quiesced", source_run_id: "r28", files: [{ path: "edition.json", sha256: sha("edition") }] }; + await expect(cloneQuiescedProductState({ source, destination, proof, candidateRunId: "candidate" })).resolves.toMatchObject({ files: 1 }); + expect(await readFile(path.join(destination, "edition.json"), "utf8")).toBe("edition"); + await expect(readFile(path.join(destination, "token.json"))).rejects.toThrow(); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "bad"), proof: { ...proof, files: [{ path: "state.sqlite", sha256: sha("") }] }, candidateRunId: "candidate2" })).rejects.toThrow(/prohibited/u); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "same"), proof, candidateRunId: "r28" })).rejects.toThrow(/differ/u); + await expect(cloneQuiescedProductState({ source, destination, proof, candidateRunId: "candidate3" })).rejects.toThrow(/not empty/u); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "drift"), proof: { ...proof, files: [{ path: "edition.json", sha256: sha("wrong") }] }, candidateRunId: "candidate4" })).rejects.toThrow(/checksum mismatch/u); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "auth"), proof: { ...proof, files: [{ path: "session/data.json", sha256: sha("") }] }, candidateRunId: "candidate5" })).rejects.toThrow(/prohibited/u); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "grok-auth"), proof: { ...proof, files: [{ path: ".grok/auth.json", sha256: sha("") }] }, candidateRunId: "candidate-auth" })).rejects.toThrow(/prohibited/u); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "duplicate"), proof: { ...proof, files: [proof.files[0], proof.files[0]] }, candidateRunId: "candidate6" })).rejects.toThrow(/duplicate/u); + await mkdir(path.join(source, "directory")); + await expect(cloneQuiescedProductState({ source, destination: path.join(root, "directory-source"), proof: { ...proof, files: [{ path: "directory", sha256: sha("") }] }, candidateRunId: "candidate7" })).rejects.toThrow(/unsafe or oversized/u); + const recovered = path.join(root, "recovered"); await mkdir(path.join(recovered, ".spawnfile-preseed-77"), { recursive: true }); await writeFile(path.join(recovered, "edition.json"), "partial"); await writeFile(path.join(recovered, ".spawnfile-product-state-preseed-journal"), JSON.stringify({ version: "spawnfile.product-state-preseed-journal.v1", candidate_run_id: "candidate8", entries: ["edition.json"], staging: ".spawnfile-preseed-77" })); + await expect(cloneQuiescedProductState({ source, destination: recovered, proof, candidateRunId: "candidate8", activationIdentity: `sha256:${"f".repeat(64)}` })).resolves.toMatchObject({ files: 1 }); expect(await readFile(path.join(recovered, "edition.json"), "utf8")).toBe("edition"); + const finalized = path.join(root, "finalized"), finalIdentity = `sha256:${"e".repeat(64)}`; await mkdir(finalized); await writeFile(path.join(finalized, "edition.json"), "edition"); await writeFile(path.join(finalized, ".spawnfile-resource-identity"), `${finalIdentity}\n`); await writeFile(path.join(finalized, ".spawnfile-product-state-preseed-journal"), JSON.stringify({ version: "spawnfile.product-state-preseed-journal.v1", candidate_run_id: "candidate9", entries: ["edition.json"], staging: ".spawnfile-preseed-88" })); await expect(cloneQuiescedProductState({ source, destination: finalized, proof, candidateRunId: "candidate9", activationIdentity: finalIdentity })).resolves.toMatchObject({ files: 1 }); + const wrongJournal = path.join(root, "wrong-journal"); await mkdir(wrongJournal); await writeFile(path.join(wrongJournal, ".spawnfile-product-state-preseed-journal"), JSON.stringify({ version: "spawnfile.product-state-preseed-journal.v1", candidate_run_id: "another", entries: [], staging: ".spawnfile-preseed-99" })); await expect(cloneQuiescedProductState({ source, destination: wrongJournal, proof, candidateRunId: "candidate10" })).rejects.toThrow(/another run/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("mechanically fences writes, emits a receipt, and rolls activation back when receipt publication fails", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-state-workflow-")); try { + const source = path.join(root, "source"); await mkdir(path.join(source, "nested"), { recursive: true }); await writeFile(path.join(source, "nested/edition.json"), "edition"); + const proofPath = path.join(root, "proof.json"), receiptPath = path.join(root, "receipt.json"), authorityReceiptPath = path.join(root, "authority.json"); + const containerId = "a".repeat(64), imageId = `sha256:${"b".repeat(64)}`, startedAt = "2026-08-25T00:00:00Z", candidateVolumeName = "candidate-volume", destination = path.join(root, "candidate"); let paused = false; + const inspect = () => [{ Id: containerId, Image: imageId, State: { Running: true, Paused: paused, StartedAt: startedAt }, Config: { Labels: { "com.spawnfile.run_id": "live" } }, Mounts: [{ Type: "volume", Name: "live-volume", Source: source, Destination: "/product", RW: true }] }]; + const docker = async (_command: string, args: string[]) => { if (args[0] === "inspect") return { stdout: JSON.stringify(inspect()) }; if (args[0] === "volume") return { stdout: JSON.stringify([{ Mountpoint: destination }]) }; if (args[0] === "pause") { paused = true; return { stdout: "" }; } if (args[0] === "unpause") { paused = false; return { stdout: "" }; } throw new Error("unexpected docker call"); }; + await expect(issueProductStateSourceSnapshot({ dockerCommand: "docker", container: "live", sourceRunId: "unrelated", mountPath: "/product", candidateVolumeName, candidateResourceIdentity: `sha256:${"c".repeat(64)}` }, docker)).rejects.toThrow(/does not own/u); + paused = true; await expect(issueProductStateSourceSnapshot({ dockerCommand: "docker", container: "live", sourceRunId: "live", mountPath: "/product", candidateVolumeName, candidateResourceIdentity: `sha256:${"c".repeat(64)}` }, docker)).resolves.toBeTruthy(); expect(paused).toBe(true); paused = false; + const snapshot = await issueProductStateSourceSnapshot({ dockerCommand: "docker", container: "live", sourceRunId: "live", mountPath: "/product", candidateVolumeName, candidateResourceIdentity: `sha256:${"c".repeat(64)}` }, docker), authority = snapshot.authority; await writeFile(authorityReceiptPath, JSON.stringify(authority)); + await writeFile(proofPath, JSON.stringify(snapshot.proof)); + await writeFile(path.join(source, "unlisted.json"), "drift"); + await mkdir(destination); + await expect(runProductStateCloneWorkflow({ authorityReceiptPath, dockerCommand: "docker", destination, proofPath, receiptPath: path.join(root, "incomplete-receipt"), candidateRunId: "incomplete" }, docker)).rejects.toThrow(/manifest/u); + await rm(path.join(source, "unlisted.json")); + await expect(runProductStateCloneWorkflow({ authorityReceiptPath, dockerCommand: "docker", destination, proofPath, receiptPath, candidateRunId: "candidate" }, docker)).resolves.toMatchObject({ version: "spawnfile.product-state-clone-receipt.v1", candidate_volume_name: candidateVolumeName }); + expect(JSON.parse(await readFile(receiptPath, "utf8"))).toMatchObject({ candidate_run_id: "candidate" }); + await writeFile(path.join(source, ".spawnfile-product-state-write-fence"), "busy"); + await expect(runProductStateCloneWorkflow({ authorityReceiptPath, dockerCommand: "docker", destination: path.join(root, "blocked"), proofPath, receiptPath: path.join(root, "blocked-receipt"), candidateRunId: "blocked" }, docker)).rejects.toThrow(); + await rm(path.join(source, ".spawnfile-product-state-write-fence")); await writeFile(path.join(root, "occupied-receipt"), "occupied"); + await rm(destination, { recursive: true }); const rollbackDestination = path.join(root, "rolled-back"); await mkdir(rollbackDestination); const rollbackDocker = async (command: string, args: string[]) => args[0] === "volume" ? { stdout: JSON.stringify([{ Mountpoint: rollbackDestination }]) } : docker(command, args); + await expect(runProductStateCloneWorkflow({ authorityReceiptPath, dockerCommand: "docker", destination: rollbackDestination, proofPath, receiptPath: path.join(root, "occupied-receipt"), candidateRunId: "rollback" }, rollbackDocker)).rejects.toThrow(); + expect(await readdir(rollbackDestination)).toEqual([]); + const stale = { ...authority, started_at: "reused" }; await writeFile(authorityReceiptPath, JSON.stringify(stale)); await expect(runProductStateCloneWorkflow({ authorityReceiptPath, dockerCommand: "docker", destination: rollbackDestination, proofPath, receiptPath: path.join(root, "stale-receipt"), candidateRunId: "stale" }, rollbackDocker)).rejects.toThrow(/stale or rebound/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("fails closed when Docker authority or the frozen identity is malformed", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-state-authority-")); try { + const source = path.join(root, "source"), destination = path.join(root, "candidate"); await mkdir(source); await writeFile(path.join(source, "edition.json"), "edition"); + const input = { dockerCommand: "docker", container: "live", sourceRunId: "live", mountPath: "/product", candidateVolumeName: "candidate", candidateResourceIdentity: `sha256:${"c".repeat(64)}` }; + await expect(issueProductStateSourceAuthority(input, async () => ({ stdout: "{}" }))).rejects.toThrow(/inspection failed/u); + const id = "a".repeat(64), image = `sha256:${"b".repeat(64)}`, started = "2026-08-25T00:00:00Z"; let paused = false; + const inspect = () => [{ Id: id, Image: image, State: { Running: true, Paused: paused, StartedAt: started }, Config: { Labels: { "com.spawnfile.run_id": "live" } }, Mounts: [{ Type: "volume", Name: "live-volume", Source: source, Destination: "/product", RW: true }] }]; + const unavailable = async (_command: string, args: string[]) => ({ stdout: args[0] === "inspect" ? JSON.stringify(inspect()) : "[]" }); + await expect(issueProductStateSourceAuthority(input, unavailable)).rejects.toThrow(/volume is unavailable/u); + const changedAfterPause = async (_command: string, args: string[]) => { + if (args[0] === "volume") return { stdout: JSON.stringify([{ Mountpoint: destination }]) }; + if (args[0] === "pause") { paused = true; return { stdout: "" }; } + if (args[0] === "unpause") { paused = false; return { stdout: "" }; } + const value = inspect(); if (paused) value[0].Image = `sha256:${"d".repeat(64)}`; return { stdout: JSON.stringify(value) }; + }; + await expect(issueProductStateSourceSnapshot(input, changedAfterPause)).rejects.toThrow(/changed before/u); expect(paused).toBe(false); + const cannotRestore = async (command: string, args: string[]) => { if (args[0] === "unpause") throw new Error("restore failed"); return changedAfterPause(command, args); }; + await expect(issueProductStateSourceSnapshot(input, cannotRestore)).rejects.toThrow(/restore failed/u); + await writeFile(path.join(source, "access-token.json"), "secret"); + await expect(createProductStateProof(source, "live")).rejects.toThrow(/prohibited/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/src/deployment/productStateClone.ts b/src/deployment/productStateClone.ts new file mode 100644 index 00000000..0ad7841e --- /dev/null +++ b/src/deployment/productStateClone.ts @@ -0,0 +1,150 @@ +import { constants } from "node:fs"; +import { chmod, copyFile, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +import { z } from "zod"; + +import { SpawnfileError } from "../shared/index.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const relative = z.string().min(1).max(255).refine((value) => !path.isAbsolute(value) && !value.split(/[\\/]/u).includes("..")); +const proofSchema = z.object({ version: z.literal("spawnfile.product-state-quiescence.v1"), state: z.literal("quiesced"), source_run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u), files: z.array(z.object({ path: relative, sha256: digest }).strict()).min(1).max(4096) }).strict(); +const forbidden = /(?:^|[._/-])(?:auth|credential|token|secret|session|wake)(?:[._/-]|$)|\.(?:db|sqlite|sqlite3)(?:-|$)/iu; +const execFile = promisify(execFileCallback); +const authoritySchema = z.object({ version: z.literal("spawnfile.product-state-source-authority.v1"), source_run_id: z.string(), container_id: z.string().regex(/^[a-f0-9]{64}$/u), image_id: z.string().regex(/^sha256:[a-f0-9]{64}$/u), started_at: z.string().min(1), was_paused: z.boolean(), source: z.string().min(1), mount_path: z.string().min(1), volume_name: z.string().min(1), candidate_volume_name: z.string().min(1), candidate_resource_identity: digest }).strict(); +type DockerExec = (command: string, args: string[]) => Promise<{ stdout: string }>; +const defaultDockerExec: DockerExec = async (command, args) => await execFile(command, args, { encoding: "utf8", maxBuffer: 1_048_576, timeout: 10_000 }); +const inspectContainer = async (docker: DockerExec, command: string, container: string): Promise> => { const value = JSON.parse((await docker(command, ["inspect", container])).stdout); if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== "object") throw new SpawnfileError("validation_error", "Managed source container inspection failed"); return value[0]; }; +const restoreRunningState = async (docker: DockerExec, command: string, authority: z.infer): Promise => { + await docker(command, ["unpause", authority.container_id]); + const restored = await inspectContainer(docker, command, authority.container_id); + if (restored.Id !== authority.container_id || restored.Image !== authority.image_id || restored.State?.StartedAt !== authority.started_at || restored.State?.Running !== true || restored.State?.Paused !== false) throw new SpawnfileError("validation_error", "Managed source state restoration could not be proven"); +}; + +export const issueProductStateSourceAuthority = async (input: { dockerCommand: string; container: string; sourceRunId: string; mountPath: string; candidateVolumeName: string; candidateResourceIdentity: string }, docker: DockerExec = defaultDockerExec): Promise> => { + const inspected = await inspectContainer(docker, input.dockerCommand, input.container), labels = inspected.Config?.Labels, state = inspected.State; + const mounts = Array.isArray(inspected.Mounts) ? inspected.Mounts.filter((mount: any) => mount.Type === "volume" && mount.Destination === input.mountPath && mount.RW === true) : []; + if (inspected.Id?.length !== 64 || !/^sha256:[a-f0-9]{64}$/u.test(inspected.Image) || labels?.["com.spawnfile.run_id"] !== input.sourceRunId || state?.Running !== true || typeof state.StartedAt !== "string" || mounts.length !== 1 || typeof mounts[0].Source !== "string" || typeof mounts[0].Name !== "string") throw new SpawnfileError("validation_error", "Source run does not own one exact managed writable state root"); + const volume = JSON.parse((await docker(input.dockerCommand, ["volume", "inspect", input.candidateVolumeName])).stdout); if (!Array.isArray(volume) || volume.length !== 1 || typeof volume[0]?.Mountpoint !== "string") throw new SpawnfileError("validation_error", "Candidate volume is unavailable"); + return authoritySchema.parse({ version: "spawnfile.product-state-source-authority.v1", source_run_id: input.sourceRunId, container_id: inspected.Id, image_id: inspected.Image, started_at: state.StartedAt, was_paused: state.Paused === true, source: mounts[0].Source, mount_path: input.mountPath, volume_name: mounts[0].Name, candidate_volume_name: input.candidateVolumeName, candidate_resource_identity: input.candidateResourceIdentity }); +}; + +const hashFile = async (file: string): Promise => { + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW); try { + const info = await handle.stat(); if (!info.isFile() || info.size > 67_108_864) throw new SpawnfileError("validation_error", "Product state file is unsafe or oversized"); + return `sha256:${createHash("sha256").update(await handle.readFile()).digest("hex")}`; + } finally { await handle.close(); } +}; +const syncDirectory = async (directory: string): Promise => { const handle = await open(directory, constants.O_RDONLY); try { await handle.sync(); } finally { await handle.close(); } }; +const syncTreeDirectories = async (directory: string): Promise => { for (const entry of await readdir(directory, { withFileTypes: true })) if (entry.isDirectory()) await syncTreeDirectories(path.join(directory, entry.name)); await syncDirectory(directory); }; + +const wholeSourceManifest = async (root: string): Promise> => { + const result = new Map(); + const visit = async (directory: string, prefix: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if ([".spawnfile-product-state-write-fence", ".spawnfile-product-state-preseed-journal", ".spawnfile-resource-identity"].includes(relativePath)) continue; + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { await visit(absolutePath, relativePath); continue; } + if (!entry.isFile() || result.size >= 4096) throw new SpawnfileError("validation_error", "Product-state source contains an unsafe or excessive entry"); + result.set(relativePath, await hashFile(absolutePath)); + } + }; + await visit(root, ""); return result; +}; +const verifyWholeSourceManifest = async (root: string, proof: z.infer): Promise => { + const actual = await wholeSourceManifest(root), expected = new Map(proof.files.map((entry) => [entry.path, entry.sha256])); + if (actual.size !== expected.size || [...actual].some(([name, checksum]) => expected.get(name) !== checksum)) throw new SpawnfileError("validation_error", "Whole product-state source manifest drifted or is incomplete"); +}; +export const createProductStateProof = async (source: string, sourceRunId: string): Promise> => { + const manifest = await wholeSourceManifest(source); const files = [...manifest].sort(([left], [right]) => left.localeCompare(right)).map(([filePath, sha256]) => { if (forbidden.test(filePath)) throw new SpawnfileError("validation_error", "Product-state source includes prohibited state"); return { path: filePath, sha256 }; }); + return proofSchema.parse({ version: "spawnfile.product-state-quiescence.v1", state: "quiesced", source_run_id: sourceRunId, files }); +}; + +export const issueProductStateSourceSnapshot = async (input: { dockerCommand: string; container: string; sourceRunId: string; mountPath: string; candidateVolumeName: string; candidateResourceIdentity: string }, docker: DockerExec = defaultDockerExec): Promise<{ authority: Record; proof: Record }> => { + const authority = authoritySchema.parse(await issueProductStateSourceAuthority(input, docker)); let pausedByUs = false; + try { + if (!authority.was_paused) { await docker(input.dockerCommand, ["pause", authority.container_id]); pausedByUs = true; } + const frozen = await inspectContainer(docker, input.dockerCommand, authority.container_id); if (frozen.Id !== authority.container_id || frozen.Image !== authority.image_id || frozen.State?.StartedAt !== authority.started_at || frozen.State?.Paused !== true) throw new SpawnfileError("validation_error", "Source changed before authoritative snapshot"); + return { authority, proof: await createProductStateProof(authority.source, authority.source_run_id) }; + } finally { if (pausedByUs) await restoreRunningState(docker, input.dockerCommand, authority); } +}; + +export const cloneQuiescedProductState = async (input: { source: string; destination: string; proof: unknown; candidateRunId: string; activationIdentity?: string }): Promise<{ files: number; source_run_id: string; candidate_run_id: string }> => { + const proof = proofSchema.parse(input.proof); if (proof.source_run_id === input.candidateRunId) throw new SpawnfileError("validation_error", "Candidate state namespace must differ from source"); + const destinationExists = await stat(input.destination).then((info) => { if (!info.isDirectory()) throw new SpawnfileError("validation_error", "Candidate state destination is not a directory"); return true; }, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(error)); + const journalPath = path.join(input.destination, ".spawnfile-product-state-preseed-journal"); + if (destinationExists && await stat(journalPath).then(() => true, () => false)) { + const journal = z.object({ version: z.literal("spawnfile.product-state-preseed-journal.v1"), candidate_run_id: z.string(), entries: z.array(relative), staging: relative }).strict().parse(JSON.parse(await readFile(journalPath, "utf8"))); + if (journal.candidate_run_id !== input.candidateRunId) throw new SpawnfileError("validation_error", "Candidate preseed journal belongs to another run"); + const identityPath = path.join(input.destination, ".spawnfile-resource-identity"), activated = input.activationIdentity && await readFile(identityPath, "utf8").then((value) => value === `${input.activationIdentity}\n`, () => false); + if (activated) { await verifyWholeSourceManifest(input.destination, proof); await rm(journalPath); await syncDirectory(input.destination); return { files: proof.files.length, source_run_id: proof.source_run_id, candidate_run_id: input.candidateRunId }; } + for (const entry of [...journal.entries, journal.staging]) await rm(path.join(input.destination, entry), { recursive: true, force: true }); await rm(journalPath); await syncDirectory(input.destination); + } + if (destinationExists && (await readdir(input.destination)).length !== 0) throw new SpawnfileError("validation_error", "Candidate state destination is not empty"); + const names = new Set(); for (const entry of proof.files) { + if (forbidden.test(entry.path) || names.has(entry.path)) throw new SpawnfileError("validation_error", "Product-state classification contains a prohibited or duplicate path"); names.add(entry.path); + if (await hashFile(path.join(input.source, entry.path)) !== entry.sha256) throw new SpawnfileError("validation_error", "Quiesced product-state checksum mismatch"); + } + const temporary = destinationExists ? path.join(input.destination, `.spawnfile-preseed-${process.pid}`) : `${input.destination}.candidate-${process.pid}`; await mkdir(temporary, { recursive: false, mode: 0o700 }); + const activated: string[] = []; try { + for (const entry of proof.files) { const target = path.join(temporary, entry.path); await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); await copyFile(path.join(input.source, entry.path), target, constants.COPYFILE_EXCL); const copied = await open(target, constants.O_RDONLY); try { await copied.sync(); } finally { await copied.close(); } if (await hashFile(target) !== entry.sha256 || await hashFile(path.join(input.source, entry.path)) !== entry.sha256) throw new SpawnfileError("validation_error", "Product state changed during clone"); } + await syncTreeDirectories(temporary); + if (destinationExists) { + const entries = await readdir(temporary), journal = await open(journalPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); try { await journal.writeFile(`${JSON.stringify({ version: "spawnfile.product-state-preseed-journal.v1", candidate_run_id: input.candidateRunId, entries, staging: path.basename(temporary) })}\n`); await journal.sync(); } finally { await journal.close(); } await syncDirectory(input.destination); + for (const entry of entries) { await rename(path.join(temporary, entry), path.join(input.destination, entry)); activated.push(path.join(input.destination, entry)); await syncDirectory(input.destination); } + await rm(temporary, { recursive: true }); await syncDirectory(input.destination); + await verifyWholeSourceManifest(input.destination, proof); + if (input.activationIdentity) { const identity = await open(path.join(input.destination, ".spawnfile-resource-identity"), constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); try { await identity.writeFile(`${input.activationIdentity}\n`); await identity.sync(); } finally { await identity.close(); } await syncDirectory(input.destination); } + await rm(journalPath); await syncDirectory(input.destination); + } else { await rename(temporary, input.destination); await syncDirectory(path.dirname(input.destination)); } + } catch (error) { + if (input.activationIdentity) { await rm(path.join(input.destination, ".spawnfile-resource-identity"), { force: true }); if (destinationExists) await syncDirectory(input.destination); } + for (const entry of activated.reverse()) { await rm(entry, { recursive: true, force: true }); if (destinationExists) await syncDirectory(input.destination); } + await rm(temporary, { recursive: true, force: true }); await syncDirectory(destinationExists ? input.destination : path.dirname(input.destination)); + await rm(journalPath, { force: true }); if (destinationExists) await syncDirectory(input.destination); throw error; + } + return { files: proof.files.length, source_run_id: proof.source_run_id, candidate_run_id: input.candidateRunId }; +}; + +export const runProductStateCloneWorkflow = async (input: { authorityReceiptPath: string; dockerCommand: string; destination: string; proofPath: string; receiptPath: string; candidateRunId: string }, docker: DockerExec = defaultDockerExec): Promise> => { + const authority = authoritySchema.parse(JSON.parse(await readFile(input.authorityReceiptPath, "utf8"))); + const candidateVolume = JSON.parse((await docker(input.dockerCommand, ["volume", "inspect", authority.candidate_volume_name])).stdout); if (!Array.isArray(candidateVolume) || candidateVolume.length !== 1 || candidateVolume[0]?.Mountpoint !== path.resolve(input.destination)) throw new SpawnfileError("validation_error", "Clone destination is not the attested candidate volume"); + const current = await inspectContainer(docker, input.dockerCommand, authority.container_id), currentMounts = Array.isArray(current.Mounts) ? current.Mounts : []; + if (current.Id !== authority.container_id || current.Image !== authority.image_id || current.State?.StartedAt !== authority.started_at || current.Config?.Labels?.["com.spawnfile.run_id"] !== authority.source_run_id || current.State?.Running !== true || current.State?.Paused !== authority.was_paused || !currentMounts.some((mount: any) => mount.Type === "volume" && mount.Name === authority.volume_name && mount.Source === authority.source && mount.Destination === authority.mount_path && mount.RW === true)) throw new SpawnfileError("validation_error", "Source run authority is stale or rebound"); + const proofBytes = await readFile(input.proofPath); if (proofBytes.length > 1_048_576) throw new SpawnfileError("validation_error", "Product-state proof is oversized"); + const proof = proofSchema.parse(JSON.parse(proofBytes.toString("utf8"))); + if (proof.source_run_id !== authority.source_run_id) throw new SpawnfileError("validation_error", "Proof source run does not match managed authority"); + const fencePath = path.join(authority.source, ".spawnfile-product-state-write-fence"); + const fence = await open(fencePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o400); + const modes: Array<{ file: string; mode: number }> = []; let pausedByUs = false; + try { + await fence.writeFile(`${proof.source_run_id}\n`); await fence.sync(); + if (!authority.was_paused) { await docker(input.dockerCommand, ["pause", authority.container_id]); pausedByUs = true; } + const frozen = await inspectContainer(docker, input.dockerCommand, authority.container_id); if (frozen.State?.Paused !== true || frozen.State?.StartedAt !== authority.started_at || frozen.Id !== authority.container_id) throw new SpawnfileError("validation_error", "Managed source cgroup did not freeze exactly"); + await verifyWholeSourceManifest(authority.source, proof); + for (const entry of proof.files) { + if (forbidden.test(entry.path)) throw new SpawnfileError("validation_error", "Product-state classification contains a prohibited path"); + const file = path.join(authority.source, entry.path), info = await stat(file); modes.push({ file, mode: info.mode & 0o777 }); await chmod(file, info.mode & ~0o222); + } + const cloned = await cloneQuiescedProductState({ source: authority.source, destination: input.destination, proof, candidateRunId: input.candidateRunId, activationIdentity: authority.candidate_resource_identity }); + await verifyWholeSourceManifest(authority.source, proof); + await verifyWholeSourceManifest(input.destination, proof); + const receipt = { version: "spawnfile.product-state-clone-receipt.v1", ...cloned, proof_sha256: `sha256:${createHash("sha256").update(proofBytes).digest("hex")}`, destination: path.resolve(input.destination), candidate_volume_name: authority.candidate_volume_name, source_container_id: authority.container_id }; + const temporaryReceipt = `${input.receiptPath}.tmp-${process.pid}`; await mkdir(path.dirname(input.receiptPath), { recursive: true, mode: 0o700 }); let published = false; + try { + const receiptFile = await open(temporaryReceipt, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + try { await receiptFile.writeFile(`${JSON.stringify(receipt)}\n`); await receiptFile.sync(); } finally { await receiptFile.close(); } + await link(temporaryReceipt, input.receiptPath); published = true; const receiptDirectory = await open(path.dirname(input.receiptPath), constants.O_RDONLY); try { await receiptDirectory.sync(); } finally { await receiptDirectory.close(); } await rm(temporaryReceipt); await syncDirectory(path.dirname(input.receiptPath)); + } catch (error) { await rm(temporaryReceipt, { force: true }); await syncDirectory(path.dirname(input.receiptPath)); if (published) { await rm(input.receiptPath, { force: true }); await syncDirectory(path.dirname(input.receiptPath)); } for (const entry of await readdir(input.destination)) { await rm(path.join(input.destination, entry), { recursive: true, force: true }); await syncDirectory(input.destination); } throw error; } + return receipt; + } finally { + let restorationError: unknown; + for (const item of modes.reverse()) try { await chmod(item.file, item.mode); } catch (error) { restorationError ??= error; } + try { await fence.close(); await rm(fencePath, { force: true }); await syncDirectory(authority.source); } catch (error) { restorationError ??= error; } + try { if (pausedByUs) await restoreRunningState(docker, input.dockerCommand, authority); } catch (error) { restorationError ??= error; } + if (restorationError) throw new SpawnfileError("validation_error", "Product-state source restoration failed", { cause: restorationError }); + } +}; diff --git a/src/deployment/workspaceResourceMigration.test.ts b/src/deployment/workspaceResourceMigration.test.ts new file mode 100644 index 00000000..a03651fa --- /dev/null +++ b/src/deployment/workspaceResourceMigration.test.ts @@ -0,0 +1,155 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { chmod, cp, lstat, mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildWorkspaceResourceManifest, + migrateWorkspaceResource, + type WorkspaceResourceMigrationHooks +} from "./workspaceResourceMigration.js"; + +const roots: string[] = []; +const resolvedIdentity = `sha256:${"b".repeat(64)}`; +const execFile = promisify(execFileCallback); + +const fixture = async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-resource-migration-")); roots.push(root); + const sourcePath = path.join(root, "r28-source"); const destinationPath = path.join(root, "volumes", "r28"); + await mkdir(path.join(sourcePath, "nested"), { recursive: true }); await chmod(sourcePath, 0o700); + await writeFile(path.join(sourcePath, "nested", "edition.jsonl"), "one\ntwo\n", { mode: 0o600 }); + await mkdir(path.dirname(destinationPath), { recursive: true }); + const manifest = await buildWorkspaceResourceManifest(sourcePath); + const manifestPath = path.join(root, "r28-manifest.json"); await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); + return { destinationPath, manifestPath, resolvedIdentity, root, sourcePath, sourceQuiesced: true as const }; +}; + +const freeSpace = { freeBytes: async () => 10_000_000n, activate: async (source: string, destination: string) => await rename(source, destination) }; +const failExisting = async (source: string, destination: string) => { + try { await lstat(destination); const error = new Error("exists") as NodeJS.ErrnoException; error.code = "EEXIST"; throw error; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + await rename(source, destination); +}; +const crashAtActivation = async (input: Awaited>, after: boolean) => { + const moduleUrl = pathToFileURL(path.resolve("src/deployment/workspaceResourceMigration.ts")).href; + const script = `import { rename } from "node:fs/promises"; import { migrateWorkspaceResource } from ${JSON.stringify(moduleUrl)}; const input=JSON.parse(process.argv[1]); await migrateWorkspaceResource({...input,sourceQuiesced:true,hooks:{freeBytes:async()=>10000000n,activate:async(from,to)=>{${after ? "await rename(from,to);" : ""}process.exit(91)}}});`; + await expect(execFile(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script, JSON.stringify(input)])).rejects.toMatchObject({ code: 91 }); +}; +const leftovers = async (root: string) => (await readdir(path.join(root, "volumes"))).filter((entry) => entry.includes(".migration-") && !entry.endsWith(".migration-journal.json")); + +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); + +describe("workspace resource live migration", () => { + it("preflights, checksums, atomically activates, and retains the source", async () => { + const input = await fixture(); + const receipt = await migrateWorkspaceResource({ ...input, hooks: freeSpace }); + expect(receipt).toMatchObject({ active_path: receipt.destination_path, rollback: false, source_retained: true, status: "activated" }); + await expect(readFile(path.join(input.destinationPath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + await expect(readFile(path.join(input.destinationPath, ".spawnfile-resource-identity"), "utf8")).resolves.toBe(`${resolvedIdentity}\n`); + expect((await stat(input.destinationPath)).mode & 0o777).toBe(0o700); + expect(await leftovers(input.root)).toEqual([]); + }); + + it("removes only temporary data after injected copy failure", async () => { + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, copy: async (_source, temporary) => { await mkdir(temporary); await writeFile(path.join(temporary, "partial"), "partial"); throw new Error("copy failed"); } } })).rejects.toThrow("copy failed"); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + expect(await leftovers(input.root)).toEqual([]); + }); + + it("removes only temporary data after injected checksum failure", async () => { + const input = await fixture(); + const hooks: WorkspaceResourceMigrationHooks = { + ...freeSpace, + copy: async (source, temporary) => await cp(source, temporary, { recursive: true, preserveTimestamps: true }), + afterCopy: async (temporary) => await writeFile(path.join(temporary, "nested", "edition.jsonl"), "corrupt\n") + }; + await expect(migrateWorkspaceResource({ ...input, hooks })).rejects.toThrow(/checksum or metadata mismatch/u); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + expect(await leftovers(input.root)).toEqual([]); + }); + + it("removes only temporary data after injected activation failure", async () => { + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, activate: async () => { throw new Error("activation failed"); } } })).rejects.toThrow("activation failed"); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + expect(await leftovers(input.root)).toEqual([]); + }); + + it("rolls back to the retained source after post-activation failure", async () => { + const input = await fixture(); + const receipt = await migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, afterActivation: async () => { throw new Error("post activation failed"); } } }); + expect(receipt).toMatchObject({ active_path: receipt.source_path, rollback: true, source_retained: true, status: "rolled_back" }); + await expect(stat(input.destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + }); + + it("detects an activation that moved data before failing and rolls it back", async () => { + const input = await fixture(); + const receipt = await migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, activate: async (temporary, destination) => { await rename(temporary, destination); throw new Error("activation acknowledgement lost"); } } }); + expect(receipt).toMatchObject({ active_path: receipt.source_path, rollback: true, source_retained: true, status: "rolled_back" }); + await expect(stat(input.destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + }); + + it("preserves an unrelated destination created by a failing activation", async () => { + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, activate: failExisting, beforeActivation: async (destination) => { await mkdir(destination); await writeFile(path.join(destination, "unrelated"), "keep\n"); } } })).rejects.toMatchObject({ code: "EEXIST" }); + await expect(readFile(path.join(input.destinationPath, "unrelated"), "utf8")).resolves.toBe("keep\n"); + }); + + it("atomically rejects a concurrently created empty destination without replacing its inode", async () => { + const input = await fixture(); let racedInode: bigint | undefined; + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, activate: failExisting, beforeActivation: async (destination) => { await mkdir(destination); racedInode = (await lstat(destination, { bigint: true })).ino; } } })).rejects.toMatchObject({ code: "EEXIST" }); + expect((await lstat(input.destinationPath, { bigint: true })).ino).toBe(racedInode); + expect(await readdir(input.destinationPath)).toEqual([]); + }); + + it("fails closed without quiescence and catches a post-copy source mutation", async () => { + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, sourceQuiesced: false })).rejects.toThrow(/requires explicit source quiescence/u); + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, afterCopy: async () => await writeFile(path.join(input.sourcePath, "nested", "edition.jsonl"), "changed\n") } })).rejects.toThrow(/checksum or metadata mismatch/u); + await expect(stat(input.destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects copied root and nested-directory metadata corruption", async () => { + const rootCorruption = await fixture(); + await expect(migrateWorkspaceResource({ ...rootCorruption, hooks: { ...freeSpace, afterCopy: async (temporary) => await chmod(temporary, 0o755) } })).rejects.toThrow(/root metadata mismatch/u); + const nestedCorruption = await fixture(); + await expect(migrateWorkspaceResource({ ...nestedCorruption, hooks: { ...freeSpace, afterCopy: async (temporary) => await chmod(path.join(temporary, "nested"), 0o700) } })).rejects.toThrow(/directory metadata mismatch/u); + }); + + it("rejects a corrupted authenticated migration identity", async () => { + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, hooks: { ...freeSpace, afterCopy: async (temporary) => await writeFile(path.join(temporary, ".spawnfile-resource-identity"), "sha256:wrong\n") } })).rejects.toThrow(/authenticated migration identity mismatch/u); + await expect(stat(input.destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed in production on a non-Linux host", async () => { + if (process.platform === "linux") return; + const input = await fixture(); + await expect(migrateWorkspaceResource({ ...input, hooks: { freeBytes: freeSpace.freeBytes } })).rejects.toThrow(/unsupported on this platform/u); + await expect(stat(input.destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("resumes exact journal-owned temporary data after a crash before activation", async () => { + const input = await fixture(); await crashAtActivation(input, false); + const receipt = await migrateWorkspaceResource({ ...input, hooks: freeSpace }); + expect(receipt.status).toBe("activated"); + await expect(readFile(path.join(input.destinationPath, "nested", "edition.jsonl"), "utf8")).resolves.toBe("one\ntwo\n"); + }); + + it("finalizes exact journal-owned destination data after a crash following activation", async () => { + const input = await fixture(); await crashAtActivation(input, true); + const before = (await lstat(input.destinationPath, { bigint: true })).ino; + const receipt = await migrateWorkspaceResource({ ...input, hooks: freeSpace }); + expect(receipt.status).toBe("activated"); + expect((await lstat(input.destinationPath, { bigint: true })).ino).toBe(before); + await expect(stat(path.join(input.destinationPath, ".spawnfile-resource-activation-provenance"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/src/deployment/workspaceResourceMigration.ts b/src/deployment/workspaceResourceMigration.ts new file mode 100644 index 00000000..86b77ea5 --- /dev/null +++ b/src/deployment/workspaceResourceMigration.ts @@ -0,0 +1,301 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { cp, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, statfs, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; + +import { activateNoReplace } from "./noReplaceActivation.js"; + +export const WORKSPACE_RESOURCE_MANIFEST_VERSION = "spawnfile.workspace-resource-manifest.v1" as const; +export const WORKSPACE_RESOURCE_MIGRATION_VERSION = "spawnfile.workspace-resource-migration.v1" as const; +const METADATA_MANIFEST = ".spawnfile-resource-migration-manifest.json"; +const ACTIVATION_PROVENANCE = ".spawnfile-resource-activation-provenance"; +const RESOURCE_IDENTITY = ".spawnfile-resource-identity"; +const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; +const MAX_JOURNAL_BYTES = 16 * 1024; + +export interface WorkspaceResourceManifestFile { + gid: number; + mode: number; + path: string; + sha256: `sha256:${string}`; + size: number; + uid: number; +} + +export interface WorkspaceResourceManifestDirectory { + gid: number; + mode: number; + path: string; + uid: number; +} + +export interface WorkspaceResourceManifest { + version: typeof WORKSPACE_RESOURCE_MANIFEST_VERSION; + root: { gid: number; mode: number; uid: number }; + directories: WorkspaceResourceManifestDirectory[]; + files: WorkspaceResourceManifestFile[]; +} + +export interface WorkspaceResourceMigrationReceipt { + version: typeof WORKSPACE_RESOURCE_MIGRATION_VERSION; + active_path: string; + destination_path: string; + manifest_sha256: `sha256:${string}`; + rollback: boolean; + source_path: string; + source_retained: true; + status: "activated" | "rolled_back"; +} + +export interface WorkspaceResourceMigrationHooks { + beforeActivation?: (destinationPath: string) => Promise; + afterActivation?: (destinationPath: string) => Promise; + afterCopy?: (temporaryPath: string) => Promise; + activate?: (temporaryPath: string, destinationPath: string) => Promise; + copy?: (sourcePath: string, temporaryPath: string) => Promise; + freeBytes?: (parentPath: string) => Promise; + withSourceWriteFence?: (operation: () => Promise) => Promise; +} + +export interface WorkspaceResourceMigrationOptions { + destinationPath: string; + hooks?: WorkspaceResourceMigrationHooks; + manifestPath: string; + resolvedIdentity: string; + sourceQuiesced?: boolean; + sourcePath: string; +} + +const digestBytes = (bytes: Uint8Array): `sha256:${string}` => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +const fileDigest = async (file: string): Promise<`sha256:${string}`> => { + const hash = createHash("sha256"); + await pipeline(createReadStream(file), hash); + return `sha256:${hash.digest("hex")}`; +}; + +const safeRelativePath = (value: unknown): string => { + if (typeof value !== "string" || !value || path.isAbsolute(value) || value.includes("\\")) throw new Error("Workspace resource manifest contains an unsafe path"); + const normalized = path.posix.normalize(value); + if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized !== value) throw new Error("Workspace resource manifest contains an unsafe path"); + return normalized; +}; + +const integer = (value: unknown, label: string): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`Workspace resource manifest ${label} is invalid`); + return value; +}; + +const exact = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); + +const parseManifest = (value: unknown): WorkspaceResourceManifest => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Workspace resource manifest is invalid"); + const root = value as Record; + if (!exact(root, ["version", "root", "directories", "files"]) || root.version !== WORKSPACE_RESOURCE_MANIFEST_VERSION || !Array.isArray(root.directories) || !Array.isArray(root.files) || !root.root || typeof root.root !== "object" || Array.isArray(root.root)) throw new Error("Workspace resource manifest is invalid"); + const rootMetadata = root.root as Record; + if (!exact(rootMetadata, ["gid", "mode", "uid"])) throw new Error("Workspace resource manifest root metadata is invalid"); + const files = root.files.map((raw): WorkspaceResourceManifestFile => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Workspace resource manifest file is invalid"); + const file = raw as Record; + if (!exact(file, ["gid", "mode", "path", "sha256", "size", "uid"]) || typeof file.sha256 !== "string" || !/^sha256:[a-f0-9]{64}$/u.test(file.sha256)) throw new Error("Workspace resource manifest file is invalid"); + return { gid: integer(file.gid, "file gid"), mode: integer(file.mode, "file mode"), path: safeRelativePath(file.path), sha256: file.sha256 as `sha256:${string}`, size: integer(file.size, "file size"), uid: integer(file.uid, "file uid") }; + }).sort((left, right) => left.path.localeCompare(right.path)); + const directories = root.directories.map((raw): WorkspaceResourceManifestDirectory => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Workspace resource manifest directory is invalid"); + const directory = raw as Record; + if (!exact(directory, ["gid", "mode", "path", "uid"])) throw new Error("Workspace resource manifest directory is invalid"); + return { gid: integer(directory.gid, "directory gid"), mode: integer(directory.mode, "directory mode"), path: safeRelativePath(directory.path), uid: integer(directory.uid, "directory uid") }; + }).sort((left, right) => left.path.localeCompare(right.path)); + const allPaths = [...directories.map((entry) => entry.path), ...files.map((entry) => entry.path)]; + if (new Set(allPaths).size !== allPaths.length || allPaths.some((entry) => entry === METADATA_MANIFEST || entry === ACTIVATION_PROVENANCE || entry === RESOURCE_IDENTITY)) throw new Error("Workspace resource manifest contains duplicate or reserved paths"); + return { version: WORKSPACE_RESOURCE_MANIFEST_VERSION, root: { gid: integer(rootMetadata.gid, "root gid"), mode: integer(rootMetadata.mode, "root mode"), uid: integer(rootMetadata.uid, "root uid") }, directories, files }; +}; + +const inventory = async (root: string, relative = ""): Promise<{ directories: string[]; files: string[] }> => { + const directories: string[] = []; const files: string[] = []; + for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) { + const child = relative ? path.posix.join(relative, entry.name) : entry.name; + if (entry.isSymbolicLink()) throw new Error(`Workspace resource contains a symlink: ${child}`); + if (entry.isDirectory()) { directories.push(child); const nested = await inventory(root, child); directories.push(...nested.directories); files.push(...nested.files); } + else if (entry.isFile()) files.push(child); + else throw new Error(`Workspace resource contains an unsupported entry: ${child}`); + } + return { directories: directories.sort(), files: files.sort() }; +}; + +const verifyTree = async (root: string, manifest: WorkspaceResourceManifest, metadataDigest?: string, internal?: { activationToken: string; resolvedIdentity: string }): Promise => { + const rootEntry = await lstat(root); + if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || rootEntry.uid !== manifest.root.uid || rootEntry.gid !== manifest.root.gid || (rootEntry.mode & 0o7777) !== manifest.root.mode) throw new Error("Workspace resource root metadata mismatch"); + const actual = await inventory(root); const internalFiles = new Set([METADATA_MANIFEST, ACTIVATION_PROVENANCE, RESOURCE_IDENTITY]); + const actualFiles = actual.files.filter((entry) => !internalFiles.has(entry)); + if (actualFiles.join("\0") !== manifest.files.map((file) => file.path).join("\0") || actual.directories.join("\0") !== manifest.directories.map((directory) => directory.path).join("\0")) throw new Error("Workspace resource inventory does not match its manifest"); + for (const expected of manifest.directories) { + const entry = await lstat(path.join(root, expected.path)); + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== expected.uid || entry.gid !== expected.gid || (entry.mode & 0o7777) !== expected.mode) throw new Error(`Workspace resource directory metadata mismatch: ${expected.path}`); + } + for (const expected of manifest.files) { + const entry = await stat(path.join(root, expected.path)); + if (!entry.isFile() || entry.size !== expected.size || entry.uid !== expected.uid || entry.gid !== expected.gid || (entry.mode & 0o7777) !== expected.mode || await fileDigest(path.join(root, expected.path)) !== expected.sha256) throw new Error(`Workspace resource checksum or metadata mismatch: ${expected.path}`); + } + if (metadataDigest !== undefined && await fileDigest(path.join(root, METADATA_MANIFEST)) !== metadataDigest) throw new Error("Workspace resource metadata manifest checksum mismatch"); + if (internal && ((await readFile(path.join(root, ACTIVATION_PROVENANCE), "utf8")) !== `${internal.activationToken}\n` || (await readFile(path.join(root, RESOURCE_IDENTITY), "utf8")) !== `${internal.resolvedIdentity}\n`)) throw new Error("Workspace resource authenticated migration identity mismatch"); +}; + +const canonicalDestination = async (destinationPath: string): Promise<{ destination: string; parent: string }> => { + if (!path.isAbsolute(destinationPath)) throw new Error("Workspace resource destination path must be absolute"); + const resolved = path.resolve(destinationPath); + const parent = await realpath(path.dirname(resolved)); + const destination = path.join(parent, path.basename(resolved)); + return { destination, parent }; +}; + +type DirectoryIdentity = { dev: number; ino: number }; +const directoryIdentity = (entry: Awaited>): DirectoryIdentity => ({ dev: Number(entry.dev), ino: Number(entry.ino) }); +const ownedDestinationIdentity = async (destination: string, token: string, expected?: DirectoryIdentity): Promise => { + try { + const entry = await lstat(destination); const marker = path.join(destination, ACTIVATION_PROVENANCE); const markerEntry = await lstat(marker); + if (!entry.isDirectory() || entry.isSymbolicLink() || !markerEntry.isFile() || markerEntry.isSymbolicLink() || (await readFile(marker, "utf8")) !== `${token}\n`) return undefined; + const actual = directoryIdentity(entry); + return expected && (expected.dev !== actual.dev || expected.ino !== actual.ino) ? undefined : actual; + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } +}; + +interface MigrationJournal { + version: "spawnfile.workspace-resource-migration-journal.v1"; + status: "prepared" | "activated"; + migration_id: string; + parent: string; + source_basename: string; + destination_basename: string; + source_path: string; + destination_path: string; + temporary_path: string; + temporary_dev: number; + temporary_ino: number; + activation_token: string; + manifest_sha256: `sha256:${string}`; + resolved_identity: string; +} + +const writeJournal = async (journalPath: string, journal: MigrationJournal): Promise => { + const bytes = `${JSON.stringify(journal)}\n`; if (Buffer.byteLength(bytes) > MAX_JOURNAL_BYTES) throw new Error("Workspace resource migration journal is too large"); + const temporary = `${journalPath}.write-${randomUUID()}`; const handle = await open(temporary, "wx", 0o600); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, journalPath); + const parentHandle = await open(path.dirname(journalPath), "r"); try { await parentHandle.sync(); } finally { await parentHandle.close(); } +}; + +const syncDirectory = async (directory: string): Promise => { const handle = await open(directory, "r"); try { await handle.sync(); } finally { await handle.close(); } }; + +const readJournal = async (journalPath: string): Promise => { + try { + const entry = await lstat(journalPath); if (!entry.isFile() || entry.isSymbolicLink() || entry.size < 1 || entry.size > MAX_JOURNAL_BYTES) throw new Error("Workspace resource migration journal is invalid"); + const value = JSON.parse(await readFile(journalPath, "utf8")) as Record; + const keys = ["version", "status", "migration_id", "parent", "source_basename", "destination_basename", "source_path", "destination_path", "temporary_path", "temporary_dev", "temporary_ino", "activation_token", "manifest_sha256", "resolved_identity"]; + if (!value || !exact(value, keys) || value.version !== "spawnfile.workspace-resource-migration-journal.v1" || !["prepared", "activated"].includes(String(value.status)) || typeof value.migration_id !== "string" || typeof value.parent !== "string" || typeof value.source_basename !== "string" || typeof value.destination_basename !== "string" || typeof value.source_path !== "string" || typeof value.destination_path !== "string" || typeof value.temporary_path !== "string" || !Number.isSafeInteger(value.temporary_dev) || !Number.isSafeInteger(value.temporary_ino) || typeof value.activation_token !== "string" || typeof value.manifest_sha256 !== "string" || typeof value.resolved_identity !== "string") throw new Error("Workspace resource migration journal is invalid"); + return value as unknown as MigrationJournal; + } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } +}; + +export const migrateWorkspaceResource = async (options: WorkspaceResourceMigrationOptions): Promise => { + if (!path.isAbsolute(options.sourcePath) || !path.isAbsolute(options.manifestPath)) throw new Error("Workspace resource source and manifest paths must be absolute"); + if (!/^sha256:[a-f0-9]{64}$/u.test(options.resolvedIdentity)) throw new Error("Workspace resource resolved identity is invalid"); + if (options.sourceQuiesced !== true && options.hooks?.withSourceWriteFence === undefined) throw new Error("Workspace resource migration requires explicit source quiescence or a write-fence authority"); + const sourceEntry = await lstat(options.sourcePath); + const source = await realpath(options.sourcePath); + if (!sourceEntry.isDirectory() || sourceEntry.isSymbolicLink()) throw new Error("Workspace resource source must be one canonical directory"); + const { destination, parent } = await canonicalDestination(options.destinationPath); + if (destination === source || destination.startsWith(`${source}${path.sep}`) || source.startsWith(`${destination}${path.sep}`)) throw new Error("Workspace resource source and destination must not overlap"); + const manifestEntry = await lstat(options.manifestPath); + if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || manifestEntry.size < 1 || manifestEntry.size > MAX_MANIFEST_BYTES) throw new Error("Workspace resource manifest must be a bounded regular file"); + const manifestBytes = await readFile(options.manifestPath); + const manifest = parseManifest(JSON.parse(manifestBytes.toString("utf8"))); + if (sourceEntry.uid !== manifest.root.uid || sourceEntry.gid !== manifest.root.gid || (sourceEntry.mode & 0o7777) !== manifest.root.mode) throw new Error("Workspace resource source owner or mode does not match its manifest"); + await verifyTree(source, manifest); + const requiredBytes = BigInt(manifest.files.reduce((total, file) => total + file.size, manifestBytes.length)); + const freeBytes = options.hooks?.freeBytes ?? (async (target: string) => { const value = await statfs(target, { bigint: true }); return value.bavail * value.bsize; }); + if (await freeBytes(parent) < requiredBytes) throw new Error("Workspace resource destination has insufficient free space"); + if (options.hooks?.activate === undefined && process.platform === "linux") { + const filesystem = await statfs(parent, { bigint: true }); + const localTypes = new Set([0xef53n, 0x58465342n, 0x9123683en, 0x01021994n, 0x794c7630n]); + if (!localTypes.has(filesystem.type)) throw new Error("Workspace resource migration requires unambiguous local filesystem authority"); + } + + const copy = options.hooks?.copy ?? (async (from, to) => await cp(from, to, { errorOnExist: true, force: false, preserveTimestamps: true, recursive: true })); + const activate = options.hooks?.activate ?? activateNoReplace; + const withSourceWriteFence = options.hooks?.withSourceWriteFence ?? (async (operation: () => Promise) => await operation()); + const manifestSha256 = digestBytes(manifestBytes); + const journalPath = `${destination}.migration-journal.json`; + const recovered = await readJournal(journalPath); + if (recovered && (recovered.parent !== parent || recovered.source_basename !== path.basename(source) || recovered.destination_basename !== path.basename(destination) || recovered.source_path !== source || recovered.destination_path !== destination || path.dirname(recovered.temporary_path) !== parent || !path.basename(recovered.temporary_path).startsWith(`${path.basename(destination)}.migration-`) || recovered.manifest_sha256 !== manifestSha256 || recovered.resolved_identity !== options.resolvedIdentity)) throw new Error("Workspace resource migration journal does not match this migration"); + let temporary = recovered?.temporary_path ?? `${destination}.migration-${randomUUID()}`; + let activationToken = recovered?.activation_token ?? randomUUID(); let migrationId = recovered?.migration_id ?? randomUUID(); + let activatedIdentity: DirectoryIdentity | undefined = recovered ? { dev: recovered.temporary_dev, ino: recovered.temporary_ino } : undefined; + let resume = false; + if (recovered) { + const destinationOwned = await ownedDestinationIdentity(destination, activationToken, activatedIdentity); + const destinationExact = await (async () => { try { const entry = await lstat(destination); const actual = directoryIdentity(entry); return actual.dev === activatedIdentity?.dev && actual.ino === activatedIdentity?.ino; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } })(); + if (destinationOwned || (recovered.status === "activated" && destinationExact && await readFile(path.join(destination, RESOURCE_IDENTITY), "utf8") === `${options.resolvedIdentity}\n`)) { + await verifyTree(destination, manifest, manifestSha256, destinationOwned ? { activationToken, resolvedIdentity: options.resolvedIdentity } : undefined); + await writeJournal(journalPath, { ...recovered, status: "activated" }); await rm(path.join(destination, ACTIVATION_PROVENANCE), { force: true }); + return { version: WORKSPACE_RESOURCE_MIGRATION_VERSION, active_path: destination, destination_path: destination, manifest_sha256: manifestSha256, rollback: false, source_path: source, source_retained: true, status: "activated" }; + } + if (await ownedDestinationIdentity(temporary, activationToken, activatedIdentity)) { await verifyTree(temporary, manifest, manifestSha256, { activationToken, resolvedIdentity: options.resolvedIdentity }); resume = true; } + else throw new Error("Workspace resource migration journal has no recoverable owned state"); + } else { + try { await lstat(destination); throw new Error("Workspace resource destination already exists"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + try { + if (!resume) { + await copy(source, temporary); + await mkdir(temporary, { recursive: true }); + await writeFile(path.join(temporary, METADATA_MANIFEST), manifestBytes, { flag: "wx", mode: 0o600 }); + await writeFile(path.join(temporary, ACTIVATION_PROVENANCE), `${activationToken}\n`, { flag: "wx", mode: 0o600 }); + await writeFile(path.join(temporary, RESOURCE_IDENTITY), `${options.resolvedIdentity}\n`, { flag: "wx", mode: 0o600 }); + await options.hooks?.afterCopy?.(temporary); + await verifyTree(temporary, manifest, manifestSha256, { activationToken, resolvedIdentity: options.resolvedIdentity }); + activatedIdentity = directoryIdentity(await lstat(temporary)); + } + if (!activatedIdentity) throw new Error("Workspace resource temporary identity is unavailable"); + const journal: MigrationJournal = { version: "spawnfile.workspace-resource-migration-journal.v1", status: "prepared", migration_id: migrationId, parent, source_basename: path.basename(source), destination_basename: path.basename(destination), source_path: source, destination_path: destination, temporary_path: temporary, temporary_dev: activatedIdentity.dev, temporary_ino: activatedIdentity.ino, activation_token: activationToken, manifest_sha256: manifestSha256, resolved_identity: options.resolvedIdentity }; + await writeJournal(journalPath, journal); + await withSourceWriteFence(async () => { + const currentSource = await lstat(source); + if (currentSource.dev !== sourceEntry.dev || currentSource.ino !== sourceEntry.ino) throw new Error("Workspace resource source identity changed before activation"); + await verifyTree(source, manifest); + await options.hooks?.beforeActivation?.(destination); + await activate(temporary, destination); + await syncDirectory(parent); + }); + if (await ownedDestinationIdentity(destination, activationToken, activatedIdentity) === undefined) throw new Error("Workspace resource activation provenance is unavailable"); + await verifyTree(destination, manifest, manifestSha256, { activationToken, resolvedIdentity: options.resolvedIdentity }); + await writeJournal(journalPath, { ...journal, status: "activated" }); + await options.hooks?.afterActivation?.(destination); + await rm(path.join(destination, ACTIVATION_PROVENANCE)); + return { version: WORKSPACE_RESOURCE_MIGRATION_VERSION, active_path: destination, destination_path: destination, manifest_sha256: manifestSha256, rollback: false, source_path: source, source_retained: true, status: "activated" }; + } catch (error) { + const owned = await ownedDestinationIdentity(destination, activationToken, activatedIdentity); + if (owned === undefined) { + await rm(temporary, { force: true, recursive: true }); await rm(journalPath, { force: true }); throw error; + } + await rm(destination, { force: true, recursive: true }); + await rm(journalPath, { force: true }); + return { version: WORKSPACE_RESOURCE_MIGRATION_VERSION, active_path: source, destination_path: destination, manifest_sha256: manifestSha256, rollback: true, source_path: source, source_retained: true, status: "rolled_back" }; + } +}; + +export const buildWorkspaceResourceManifest = async (sourcePath: string): Promise => { + const source = await realpath(sourcePath); const root = await stat(source); const entries = await inventory(source); + const directories = await Promise.all(entries.directories.map(async (relative): Promise => { + const entry = await lstat(path.join(source, relative)); + return { gid: entry.gid, mode: entry.mode & 0o7777, path: relative, uid: entry.uid }; + })); + const files = await Promise.all(entries.files.map(async (relative): Promise => { + const entry = await stat(path.join(source, relative)); + return { gid: entry.gid, mode: entry.mode & 0o7777, path: relative, sha256: await fileDigest(path.join(source, relative)), size: entry.size, uid: entry.uid }; + })); + return { version: WORKSPACE_RESOURCE_MANIFEST_VERSION, root: { gid: root.gid, mode: root.mode & 0o7777, uid: root.uid }, directories, files }; +}; From 08c83eeb9b4187c084aaa3f7d5d32d0a7ec1b14f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:18 +0200 Subject: [PATCH 10/34] feat(deployment): cut over verified runtime candidates --- src/cli/canaryCutoverCommand.ts | 16 +++++ src/cli/index.ts | 15 +++- src/deployment/canaryCutover.test.ts | 68 ++++++++++++++++++ src/deployment/canaryCutover.ts | 92 ++++++++++++++++++++++++ src/deployment/dockerManager.ts | 12 +++- src/deployment/index.ts | 2 + src/deployment/organizationReady.test.ts | 13 ++++ src/deployment/organizationReady.ts | 31 ++++++-- 8 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 src/cli/canaryCutoverCommand.ts create mode 100644 src/deployment/canaryCutover.test.ts create mode 100644 src/deployment/canaryCutover.ts diff --git a/src/cli/canaryCutoverCommand.ts b/src/cli/canaryCutoverCommand.ts new file mode 100644 index 00000000..d5d6d9b7 --- /dev/null +++ b/src/cli/canaryCutoverCommand.ts @@ -0,0 +1,16 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { z } from "zod"; +import { issueDeploymentIdentity, runCanaryCutover, verifyEquivalentRollbackReadiness } from "../deployment/canaryCutover.js"; + +const boundedArgs = z.array(z.string().max(4096)).max(32); +const request = z.object({ version: z.literal("spawnfile.canary-cutover-request.v1"), live_report_path: z.string().min(1), candidate_report_path: z.string().min(1), readiness_path: z.string().min(1), expected_identity: z.unknown(), docker_command: z.string().min(1), nonce: z.string().regex(/^[a-f0-9]{32}$/u), transaction_path: z.string().min(1), ingress_command: z.string().min(1), ingress_args: boundedArgs, ingress_receipt_path: z.string().min(1), teardown_command: z.string().min(1), teardown_args: boundedArgs, teardown_policy: z.enum(["export", "force"]), teardown_project_path: z.string().min(1), teardown_compiled_path: z.string().min(1), decision_receipt_path: z.string().min(1), from_deployment: z.string().min(1), to_deployment: z.string().min(1) }).strict(); +const rollback = z.object({ version: z.literal("spawnfile.canary-rollback-readiness-request.v1"), rollback_readiness_path: z.string().min(1), expected_identity: z.unknown() }).strict(); +const identity = z.object({ version: z.literal("spawnfile.deployment-identity-request.v1"), readiness_path: z.string().min(1), deployment_mode: z.enum(["project", "image"]), docker_command: z.string().min(1), receipt_path: z.string().min(1) }).strict(); +export const isCanaryCutoverInvocation = (argv: readonly string[]): boolean => argv[0] === "canary" && ["identity", "cutover", "verify-rollback"].includes(argv[1] ?? ""); +export const runCanaryCutoverCommand = async (argv: readonly string[]): Promise => { try { + const bytes = await readFile(argv[2] ?? "", "utf8"); + if (argv[1] === "identity") { const value = identity.parse(JSON.parse(bytes)), receipt = await issueDeploymentIdentity(value.readiness_path, value.deployment_mode, value.docker_command); await writeFile(value.receipt_path, `${JSON.stringify(receipt)}\n`, { flag: "wx", mode: 0o600 }); } + else if (argv[1] === "verify-rollback") { const value = rollback.parse(JSON.parse(bytes)); await verifyEquivalentRollbackReadiness(value.rollback_readiness_path, value.expected_identity); } + else { const value = request.parse(JSON.parse(bytes)); await runCanaryCutover({ liveReportPath: value.live_report_path, candidateReportPath: value.candidate_report_path, readinessPath: value.readiness_path, expectedIdentity: value.expected_identity, dockerCommand: value.docker_command, nonce: value.nonce, transactionPath: value.transaction_path, ingressCommand: value.ingress_command, ingressArgs: value.ingress_args, ingressReceiptPath: value.ingress_receipt_path, teardownCommand: value.teardown_command, teardownArgs: value.teardown_args, teardownPolicy: value.teardown_policy, teardownProjectPath: value.teardown_project_path, teardownCompiledPath: value.teardown_compiled_path, decisionReceiptPath: value.decision_receipt_path, fromDeployment: value.from_deployment, toDeployment: value.to_deployment }); } + return 0; +} catch { process.stderr.write("error: Canary operation failed\n"); return 1; } }; diff --git a/src/cli/index.ts b/src/cli/index.ts index d6a6c37c..66154b51 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,9 +1,18 @@ #!/usr/bin/env node import { isTargetLookupInvocation } from "./targetCliRoute.js"; +import { isWorkspaceResourceMigrationInvocation } from "./workspaceResourceMigrationCommand.js"; +import { isProductStateCloneInvocation } from "./productStateCloneCommand.js"; +import { isCanaryCutoverInvocation } from "./canaryCutoverCommand.js"; const argv = process.argv.slice(2); -const exitCode = isTargetLookupInvocation(argv) - ? await (await import("./targetLookupCli.js")).runTargetLookupCli(argv) - : await (await import("./runCli.js")).runCli(argv); +const exitCode = isCanaryCutoverInvocation(argv) + ? await (await import("./canaryCutoverCommand.js")).runCanaryCutoverCommand(argv) + : isProductStateCloneInvocation(argv) + ? await (await import("./productStateCloneCommand.js")).runProductStateCloneCommand(argv) + : isWorkspaceResourceMigrationInvocation(argv) + ? await (await import("./workspaceResourceMigrationCommand.js")).runWorkspaceResourceMigrationCommand(argv) + : isTargetLookupInvocation(argv) + ? await (await import("./targetLookupCli.js")).runTargetLookupCli(argv) + : await (await import("./runCli.js")).runCli(argv); process.exitCode = exitCode; diff --git a/src/deployment/canaryCutover.test.ts b/src/deployment/canaryCutover.test.ts new file mode 100644 index 00000000..d39c3c2d --- /dev/null +++ b/src/deployment/canaryCutover.test.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { issueDeploymentIdentity, runCanaryCutover, verifyCandidatePortIsolation, verifyEquivalentRollbackReadiness } from "./canaryCutover.js"; + +const sha = (value: string | Buffer): string => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item); +const projectPath = "/absolute/project", compiledPath = "/absolute/compiled"; +const ready = (run = "candidate", fingerprint = "sf1:aaaaaaaaaaaa") => ({ version: "spawnfile.up-receipt.v1", run_id: run, fingerprint, deployment: { name: run, container_ids: [`${run}-container`] }, readiness: { state: "running", moltnet_base_url: null }, compiled_schedule: [{ agent: "agent:a", cron: "0 5 * * *" }], engines: [{ agent: "agent:a", engine: "codex" }], organization_ready: { version: "spawnfile.organization-ready.v1", state: "ready", code: "organization_ready", run_id: run, unit_id: "unit-a", compile_fingerprint: fingerprint, world_binding_digest: `sha256:${"a".repeat(64)}` } }); +const identity = (receipt: ReturnType, mode: "project" | "image" = "project") => ({ version: "spawnfile.deployment-identity.v1", run_id: receipt.run_id, fingerprint: receipt.fingerprint, deployment_name: receipt.deployment.name, deployment_mode: mode, image_id: `sha256:${"b".repeat(64)}`, organization_unit_id: receipt.organization_ready.unit_id, organization_compile_fingerprint: receipt.organization_ready.compile_fingerprint, organization_world_binding_digest: receipt.organization_ready.world_binding_digest, topology_sha256: sha(canonical({ compiled_schedule: receipt.compiled_schedule, engines: receipt.engines, moltnet_release: null })) }); + +describe("generic canary cutover", () => { + it("reserves a nonce, rejects stale receipts, and reconciles ingress before a final decision", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-cutover-")); try { + expect(() => verifyCandidatePortIsolation({ published_ports: [8080] }, { published_ports: [8080] })).toThrow(/isolated/u); + expect(() => verifyCandidatePortIsolation({ published_ports: [] }, { published_ports: [18080, 18080] })).toThrow(/isolated/u); + expect(() => verifyCandidatePortIsolation( + { published_ports: [8080], persistent_mounts: [{ id: "provider-realm" }] }, + { published_ports: [18080], persistent_mounts: [{ id: "provider-realm", lifecycle: "exclusive-reattach" }] } + )).toThrow(/stop-and-reattach/u); + const live = path.join(root, "live.json"), candidate = path.join(root, "candidate.json"), readinessPath = path.join(root, "ready.json"), ingress = path.join(root, "ingress.json"), decision = path.join(root, "decision.json"), transaction = path.join(root, "transaction.json"); + const receipt = ready(), readinessBytes = Buffer.from(`${JSON.stringify(receipt)}\n`), expected = identity(receipt), operationNonce = "1".repeat(32); + await writeFile(live, JSON.stringify({ published_ports: [8080] })); await writeFile(candidate, JSON.stringify({ published_ports: [18080] })); await writeFile(readinessPath, readinessBytes); + await expect(issueDeploymentIdentity(readinessPath, "project", "docker", async () => expected.image_id)).resolves.toEqual(expected); + const input = { liveReportPath: live, candidateReportPath: candidate, readinessPath, expectedIdentity: expected, dockerCommand: "docker", nonce: operationNonce, transactionPath: transaction, ingressCommand: "switch", ingressArgs: [], ingressReceiptPath: ingress, teardownCommand: "spawnfile", teardownArgs: ["down", projectPath, "--compiled", compiledPath, "--deployment", "live", "--force", "--json", "--lifecycle-invocation", `lci_canary_${operationNonce}`], teardownPolicy: "force" as const, teardownProjectPath: projectPath, teardownCompiledPath: compiledPath, decisionReceiptPath: decision, fromDeployment: "live", toDeployment: "candidate" }; + const calls: string[] = []; let logicalTeardowns = 0, tornDown = false, teardownArgv: string[] = []; const runner = async (command: string, args: string[]) => { calls.push(command); if (command === "switch") { await writeFile(ingress, JSON.stringify({ version: "spawnfile.ingress-cutover-receipt.v1", state: "switched", nonce: operationNonce, from_deployment: "live", to_deployment: "candidate", target_run_id: "candidate", readiness_sha256: sha(readinessBytes) })); return; } teardownArgv = args; if (!tornDown) { tornDown = true; logicalTeardowns += 1; } return JSON.stringify({ version: "spawnfile.down-receipt.v1", deployment: "live", units_stopped: ["live-container"], retained_volumes: ["state"], errors: [] }); }; + await expect(runCanaryCutover(input, runner, async () => expected.image_id)).resolves.toMatchObject({ decision: "cutover", target_run_id: "candidate" }); expect(calls).toEqual(["switch", "spawnfile"]); expect(teardownArgv.slice(0, 4)).toEqual(["down", projectPath, "--compiled", compiledPath]); + await expect(runCanaryCutover(input, runner, async () => expected.image_id)).resolves.toEqual(JSON.parse(await readFile(decision, "utf8"))); expect(calls).toEqual(["switch", "spawnfile"]); + const checkpointPath = `${transaction}.teardown-complete`, checkpointBytes = await readFile(checkpointPath); await rm(checkpointPath); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).rejects.toThrow(/without its teardown checkpoint/u); await writeFile(checkpointPath, checkpointBytes); + await rm(decision); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).resolves.toMatchObject({ decision: "cutover" }); expect(calls).toEqual(["switch", "spawnfile"]); + await rm(decision); await rm(`${transaction}.teardown-complete`); await rm(`${transaction}.teardown-receipt`); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).resolves.toMatchObject({ decision: "cutover" }); expect(calls).toEqual(["switch", "spawnfile", "spawnfile"]); expect(logicalTeardowns).toBe(1); + const validDecision = JSON.parse(await readFile(decision, "utf8")); await writeFile(decision, JSON.stringify({ ...validDecision, target_run_id: "other" })); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).rejects.toThrow(/does not match/u); const { teardown_checkpoint_sha256: _missing, ...missingDigest } = validDecision; await writeFile(decision, JSON.stringify(missingDigest)); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).rejects.toThrow(); await writeFile(decision, JSON.stringify({ ...validDecision, down_receipt_sha256: `sha256:${"0".repeat(64)}` })); await expect(runCanaryCutover(input, runner, async () => expected.image_id)).rejects.toThrow(/does not match/u); await writeFile(decision, JSON.stringify(validDecision)); + const staleRoot = path.join(root, "stale"); await writeFile(staleRoot, "stale"); await expect(runCanaryCutover({ ...input, transactionPath: path.join(root, "new-transaction"), ingressReceiptPath: staleRoot, decisionReceiptPath: path.join(root, "new-decision") }, runner, async () => expected.image_id)).rejects.toThrow(/Stale/u); + await expect(runCanaryCutover({ ...input, nonce: "2".repeat(32), teardownArgs: ["down", projectPath, "--compiled", compiledPath, "--deployment", "live", "--force", "--json", "--lifecycle-invocation", `lci_canary_${"2".repeat(32)}`] }, runner, async () => expected.image_id)).rejects.toThrow(/authority/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("binds rollback readiness to the exact prior organization, image-independent topology, and run", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-rollback-")); try { const prior = ready("live", "sf1:bbbbbbbbbbbb"), file = path.join(root, "ready.json"); await writeFile(file, JSON.stringify(prior)); await expect(verifyEquivalentRollbackReadiness(file, identity(prior, "image"))).resolves.toBeUndefined(); await expect(verifyEquivalentRollbackReadiness(file, { ...identity(prior, "image"), run_id: "other" })).rejects.toThrow(/exact intended/u); const noWorld = { ...prior, organization_ready: { ...prior.organization_ready, world_binding_digest: null } }, noWorldPath = path.join(root, "no-world.json"); await writeFile(noWorldPath, JSON.stringify(noWorld)); await expect(issueDeploymentIdentity(noWorldPath, "project", "docker", async () => `sha256:${"b".repeat(64)}`)).resolves.toMatchObject({ organization_world_binding_digest: null }); } finally { await rm(root, { recursive: true, force: true }); } + }); + it("fails closed on image, container, bounds, and nonce drift", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-cutover-hostile-")); try { + const live = path.join(root, "live.json"), candidate = path.join(root, "candidate.json"), readinessPath = path.join(root, "ready.json"), receipt = ready(), bytes = Buffer.from(JSON.stringify(receipt)), expected = identity(receipt); + await writeFile(live, JSON.stringify({ published_ports: [80] })); await writeFile(candidate, JSON.stringify({ published_ports: [81] })); await writeFile(readinessPath, bytes); + const base = { liveReportPath: live, candidateReportPath: candidate, readinessPath, expectedIdentity: expected, dockerCommand: "docker", nonce: "3".repeat(32), transactionPath: path.join(root, "transaction"), ingressCommand: "switch", ingressArgs: [], ingressReceiptPath: path.join(root, "ingress"), teardownCommand: "spawnfile", teardownArgs: ["down", projectPath, "--compiled", compiledPath, "--deployment", "live", "--force", "--json", "--lifecycle-invocation", `lci_canary_${"3".repeat(32)}`], teardownPolicy: "force" as const, teardownProjectPath: projectPath, teardownCompiledPath: compiledPath, decisionReceiptPath: path.join(root, "decision"), fromDeployment: "live", toDeployment: "candidate" }; + await expect(runCanaryCutover({ ...base, ingressArgs: Array(33).fill("x") }, async () => {}, async () => expected.image_id)).rejects.toThrow(/bounds/u); + await expect(runCanaryCutover({ ...base, teardownPolicy: "export" }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownPolicy: "export", teardownArgs: ["down", "--deployment", "live", "--export-to"] }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownArgs: ["down", "--json"] }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownArgs: base.teardownArgs.map((value, index) => index === 5 ? "other" : value) }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownArgs: [...base.teardownArgs, "--force"] }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownArgs: [...base.teardownArgs, "--timeout", "1"] }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover({ ...base, teardownProjectPath: "relative/project" }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + const alternate = "/absolute/../absolute/project"; await expect(runCanaryCutover({ ...base, teardownProjectPath: alternate, teardownArgs: base.teardownArgs.map((value, index) => index === 1 ? alternate : value) }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + const trailing = `${compiledPath}/`; await expect(runCanaryCutover({ ...base, teardownCompiledPath: trailing, teardownArgs: base.teardownArgs.map((value, index) => index === 3 ? trailing : value) }, async () => {}, async () => expected.image_id)).rejects.toThrow(/canonical/u); + await expect(runCanaryCutover(base, async () => {}, async () => `sha256:${"c".repeat(64)}`)).rejects.toThrow(/image/u); + await expect(issueDeploymentIdentity(readinessPath, "project", "docker", async (_command, args) => args.at(-1) === "candidate-container" ? expected.image_id : `sha256:${"c".repeat(64)}`)).resolves.toMatchObject({ image_id: expected.image_id }); + const split = { ...receipt, deployment: { ...receipt.deployment, container_ids: ["one", "two"] } }, splitPath = path.join(root, "split"); await writeFile(splitPath, JSON.stringify(split)); await expect(issueDeploymentIdentity(splitPath, "project", "docker", async (_command, args) => args.at(-1) === "one" ? expected.image_id : `sha256:${"c".repeat(64)}`)).rejects.toThrow(/one exact image/u); + const noContainers = { ...receipt, deployment: { ...receipt.deployment, container_ids: [] } }, noContainersPath = path.join(root, "no-containers"); await writeFile(noContainersPath, JSON.stringify(noContainers)); await expect(issueDeploymentIdentity(noContainersPath, "project", "docker", async () => expected.image_id)).rejects.toThrow(/identity-ready/u); + await expect(runCanaryCutover({ ...base, readinessPath: noContainersPath, transactionPath: path.join(root, "empty-transaction") }, async () => {}, async () => expected.image_id)).rejects.toThrow(/no image-bearing/u); + const noEngines = { ...receipt }; delete (noEngines as Partial).engines; const noEnginesPath = path.join(root, "no-engines"); await writeFile(noEnginesPath, JSON.stringify(noEngines)); await expect(issueDeploymentIdentity(noEnginesPath, "project", "docker", async () => expected.image_id)).resolves.toMatchObject({ run_id: "candidate" }); + const wrongIngress = async () => { await writeFile(base.ingressReceiptPath, JSON.stringify({ version: "spawnfile.ingress-cutover-receipt.v1", state: "switched", nonce: "4".repeat(32), from_deployment: "live", to_deployment: "candidate", target_run_id: "candidate", readiness_sha256: sha(bytes) })); }; + await expect(runCanaryCutover(base, wrongIngress, async () => expected.image_id)).rejects.toThrow(/nonce/u); + const fake = { ...base, nonce: "5".repeat(32), transactionPath: path.join(root, "fake-transaction"), ingressReceiptPath: path.join(root, "fake-ingress"), decisionReceiptPath: path.join(root, "fake-decision"), teardownArgs: ["down", projectPath, "--compiled", compiledPath, "--deployment", "live", "--force", "--json", "--lifecycle-invocation", `lci_canary_${"5".repeat(32)}`] }; await expect(runCanaryCutover(fake, async (command) => { if (command === "switch") { await writeFile(fake.ingressReceiptPath, JSON.stringify({ version: "spawnfile.ingress-cutover-receipt.v1", state: "switched", nonce: fake.nonce, from_deployment: "live", to_deployment: "candidate", target_run_id: "candidate", readiness_sha256: sha(bytes) })); return; } return JSON.stringify({ version: "spawnfile.down-receipt.v1", deployment: "other", units_stopped: [], retained_volumes: [], errors: [] }); }, async () => expected.image_id)).rejects.toThrow(/exact teardown/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/src/deployment/canaryCutover.ts b/src/deployment/canaryCutover.ts new file mode 100644 index 00000000..546be7ec --- /dev/null +++ b/src/deployment/canaryCutover.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { execFile as execFileCallback } from "node:child_process"; +import { link, open, readFile, rm } from "node:fs/promises"; +import { promisify } from "node:util"; +import path from "node:path"; +import { z } from "zod"; +import { SpawnfileError } from "../shared/index.js"; +import { parseUpReceipt, type UpReceipt } from "./upReceiptTypes.js"; +import { parseDownReceipt } from "./downReceiptTypes.js"; + +const execFile = promisify(execFileCallback); +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u), runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u), nonce = z.string().regex(/^[a-f0-9]{32}$/u); +const ingressReceiptSchema = z.object({ version: z.literal("spawnfile.ingress-cutover-receipt.v1"), state: z.literal("switched"), nonce, from_deployment: z.string().min(1), to_deployment: z.string().min(1), target_run_id: runId, readiness_sha256: digest }).strict(); +const decisionReceiptSchema = z.object({ version: z.literal("spawnfile.canary-decision-receipt.v1"), decision: z.literal("cutover"), nonce, target_run_id: runId, deployment_mode: z.enum(["project", "image"]), target_identity_sha256: digest, ingress_receipt_sha256: digest, teardown_checkpoint_sha256: digest, down_receipt_sha256: digest }).strict(); +const reportSchema = z.object({ + persistent_mounts: z.array(z.object({ + id: z.string().min(1), + lifecycle: z.literal("exclusive-reattach").optional() + }).passthrough()).max(4096).optional().default([]), + published_ports: z.array(z.number().int().min(1).max(65535)).max(64) +}).passthrough(); +export const deploymentIdentitySchema = z.object({ version: z.literal("spawnfile.deployment-identity.v1"), run_id: runId, fingerprint: z.string().min(1), deployment_name: z.string().min(1), deployment_mode: z.enum(["project", "image"]), image_id: z.string().regex(/^sha256:[a-f0-9]{64}$/u), organization_unit_id: z.string().min(1), organization_compile_fingerprint: z.string().min(1), organization_world_binding_digest: digest.nullable(), topology_sha256: digest }).strict(); +type Execute = (command: string, args: string[]) => Promise; +const execute: Execute = async (command, args) => (await execFile(command, args, { encoding: "utf8", timeout: 30_000, maxBuffer: 1_048_576 })).stdout; +type Inspect = (command: string, args: string[]) => Promise; +const inspect: Inspect = async (command, args) => (await execFile(command, args, { encoding: "utf8", timeout: 10_000, maxBuffer: 1_048_576 })).stdout; +const sha = (bytes: Buffer | string): string => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +const canonical = (value: unknown): string => JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([left], [right]) => left.localeCompare(right))) : item); +const topologyDigest = (receipt: UpReceipt): string => sha(canonical({ compiled_schedule: receipt.compiled_schedule, engines: receipt.engines ?? [], moltnet_release: receipt.moltnet_release ?? null })); + +export const verifyCandidatePortIsolation = (liveReport: unknown, candidateReport: unknown): number[] => { + const liveReportValue = reportSchema.parse(liveReport), candidateReportValue = reportSchema.parse(candidateReport); + const live = new Set(liveReportValue.published_ports), candidate = candidateReportValue.published_ports; + if (candidate.some((port) => live.has(port)) || new Set(candidate).size !== candidate.length) throw new SpawnfileError("validation_error", "Candidate published ports are not mechanically isolated from live"); + const liveMounts = new Map(liveReportValue.persistent_mounts.map((mount) => [mount.id, mount])); + const sharedExclusive = candidateReportValue.persistent_mounts.find((mount) => + liveMounts.has(mount.id) + && (mount.lifecycle === "exclusive-reattach" || liveMounts.get(mount.id)?.lifecycle === "exclusive-reattach") + ); + if (sharedExclusive) throw new SpawnfileError( + "validation_error", + `Candidate shares exclusive persistent mount ${sharedExclusive.id}; use stop-and-reattach deployment instead of concurrent canary` + ); + return candidate; +}; +const verifyIdentity = (receipt: UpReceipt, expected: z.infer): void => { + const ready = receipt.organization_ready; + if (receipt.run_id !== expected.run_id || receipt.fingerprint !== expected.fingerprint || receipt.deployment.name !== expected.deployment_name || ready?.state !== "ready" || ready.unit_id !== expected.organization_unit_id || ready.compile_fingerprint !== expected.organization_compile_fingerprint || ready.world_binding_digest !== expected.organization_world_binding_digest || topologyDigest(receipt) !== expected.topology_sha256) throw new SpawnfileError("validation_error", "Readiness does not bind the exact intended deployment identity and topology"); +}; +export const issueDeploymentIdentity = async (readinessPath: string, deploymentMode: "project" | "image", dockerCommand: string, imageInspect: Inspect = inspect): Promise> => { + const receipt = parseUpReceipt(JSON.parse(await readFile(readinessPath, "utf8"))), ready = receipt.organization_ready; + if (receipt.run_id === null || receipt.deployment.name === null || receipt.deployment.container_ids.length < 1 || ready?.state !== "ready") throw new SpawnfileError("validation_error", "Deployment is not identity-ready"); + const images = await Promise.all(receipt.deployment.container_ids.map(async (container) => (await imageInspect(dockerCommand, ["inspect", "--format", "{{.Image}}", container])).trim())); + if (!images[0] || images.some((image) => image !== images[0])) throw new SpawnfileError("validation_error", "Deployment containers do not share one exact image identity"); + return deploymentIdentitySchema.parse({ version: "spawnfile.deployment-identity.v1", run_id: receipt.run_id, fingerprint: receipt.fingerprint, deployment_name: receipt.deployment.name, deployment_mode: deploymentMode, image_id: images[0], organization_unit_id: ready.unit_id, organization_compile_fingerprint: ready.compile_fingerprint, organization_world_binding_digest: ready.world_binding_digest, topology_sha256: topologyDigest(receipt) }); +}; +const syncParent = async (filePath: string): Promise => { const directory = await open(path.dirname(filePath), "r"); try { await directory.sync(); } finally { await directory.close(); } }; +const publishExclusive = async (filePath: string, value: unknown): Promise => { const temporary = `${filePath}.tmp-${process.pid}`; const file = await open(temporary, "wx", 0o600); try { await file.writeFile(`${JSON.stringify(value)}\n`); await file.sync(); } finally { await file.close(); } try { await link(temporary, filePath); await syncParent(filePath); } finally { await rm(temporary, { force: true }); await syncParent(filePath); } }; +const exists = async (filePath: string): Promise => await readFile(filePath).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(error)); + +export const runCanaryCutover = async (input: { liveReportPath: string; candidateReportPath: string; readinessPath: string; expectedIdentity: unknown; dockerCommand: string; nonce: string; transactionPath: string; ingressCommand: string; ingressArgs: string[]; ingressReceiptPath: string; teardownCommand: string; teardownArgs: string[]; teardownPolicy: "export" | "force"; teardownProjectPath: string; teardownCompiledPath: string; decisionReceiptPath: string; fromDeployment: string; toDeployment: string }, runner: Execute = execute, imageInspect: Inspect = inspect): Promise> => { + const expected = deploymentIdentitySchema.parse(input.expectedIdentity), operationNonce = nonce.parse(input.nonce); + if (input.ingressArgs.length > 32 || input.teardownArgs.length > 32 || [...input.ingressArgs, ...input.teardownArgs].some((arg) => arg.length > 4096)) throw new SpawnfileError("validation_error", "Canary command arguments exceed bounds"); + const lifecycleId = `lci_canary_${operationNonce}`, exportIndex = input.teardownArgs.indexOf("--export-to"), prefix = ["down", input.teardownProjectPath, "--compiled", input.teardownCompiledPath, "--deployment", input.fromDeployment], expectedTeardownArgs = input.teardownPolicy === "export" ? [...prefix, "--export-to", input.teardownArgs[exportIndex + 1] ?? "", "--json", "--lifecycle-invocation", lifecycleId] : [...prefix, "--force", "--json", "--lifecycle-invocation", lifecycleId]; + const canonicalPath = (value: string): boolean => path.isAbsolute(value) && path.resolve(value) === value && path.normalize(value) === value; + if (!canonicalPath(input.teardownProjectPath) || !canonicalPath(input.teardownCompiledPath) || path.basename(input.teardownCommand) !== "spawnfile" || (input.teardownPolicy === "export" && (exportIndex < 0 || !expectedTeardownArgs[7])) || canonical(input.teardownArgs) !== canonical(expectedTeardownArgs)) throw new SpawnfileError("validation_error", "Teardown is not the exact canonical Spawnfile lifecycle down operation"); + verifyCandidatePortIsolation(JSON.parse(await readFile(input.liveReportPath, "utf8")), JSON.parse(await readFile(input.candidateReportPath, "utf8"))); + const readinessBytes = await readFile(input.readinessPath), readiness = parseUpReceipt(JSON.parse(readinessBytes.toString("utf8"))); verifyIdentity(readiness, expected); + if (readiness.deployment.container_ids.length < 1) throw new SpawnfileError("validation_error", "Ready deployment has no image-bearing containers"); + for (const container of readiness.deployment.container_ids) if ((await imageInspect(input.dockerCommand, ["inspect", "--format", "{{.Image}}", container])).trim() !== expected.image_id) throw new SpawnfileError("validation_error", "Ready deployment image does not match intended identity"); + const binding = sha(canonical({ ...input, expectedIdentity: expected })); + const transaction = { version: "spawnfile.canary-transaction.v1", state: "reserved", nonce: operationNonce, binding_sha256: binding }; + const recovering = await exists(input.transactionPath); + if (recovering) { const prior = JSON.parse(await readFile(input.transactionPath, "utf8")); if (prior.version !== transaction.version || prior.nonce !== operationNonce || prior.binding_sha256 !== binding) throw new SpawnfileError("validation_error", "Canary transaction authority does not match"); } + else { if (await exists(input.ingressReceiptPath) || await exists(input.decisionReceiptPath)) throw new SpawnfileError("validation_error", "Stale canary receipt exists before transaction reservation"); await publishExclusive(input.transactionPath, transaction); } + if (!await exists(input.ingressReceiptPath)) await runner(input.ingressCommand, input.ingressArgs); + const ingressBytes = await readFile(input.ingressReceiptPath), ingress = ingressReceiptSchema.parse(JSON.parse(ingressBytes.toString("utf8"))); + if (ingress.nonce !== operationNonce || ingress.from_deployment !== input.fromDeployment || ingress.to_deployment !== input.toDeployment || ingress.target_run_id !== expected.run_id || ingress.readiness_sha256 !== sha(readinessBytes)) throw new SpawnfileError("validation_error", "Ingress receipt does not bind the exact ready target and transaction nonce"); + const teardownStartedPath = `${input.transactionPath}.teardown-started`, teardownReceiptPath = `${input.transactionPath}.teardown-receipt`, teardownCheckpointPath = `${input.transactionPath}.teardown-complete`, checkpointBase = { nonce: operationNonce, binding_sha256: binding, ingress_receipt_sha256: sha(ingressBytes), lifecycle_invocation: lifecycleId }; + if (!await exists(teardownCheckpointPath)) { + if (await exists(input.decisionReceiptPath)) throw new SpawnfileError("validation_error", "Canary decision exists without its teardown checkpoint"); + if (!await exists(teardownStartedPath)) await publishExclusive(teardownStartedPath, { version: "spawnfile.canary-teardown-started.v1", ...checkpointBase }); + if (!await exists(teardownReceiptPath)) { const output = await runner(input.teardownCommand, input.teardownArgs), down = parseDownReceipt(JSON.parse(typeof output === "string" ? output : "")); if (down.deployment !== input.fromDeployment || down.errors.length !== 0) throw new SpawnfileError("validation_error", "Down receipt does not prove exact teardown completion"); await publishExclusive(teardownReceiptPath, down); } + const downBytes = await readFile(teardownReceiptPath), down = parseDownReceipt(JSON.parse(downBytes.toString("utf8"))); if (down.deployment !== input.fromDeployment || down.errors.length !== 0) throw new SpawnfileError("validation_error", "Stored down receipt does not bind the teardown operation"); await publishExclusive(teardownCheckpointPath, { version: "spawnfile.canary-teardown-checkpoint.v1", ...checkpointBase, down_receipt_sha256: sha(downBytes) }); + } + else { const downBytes = await readFile(teardownReceiptPath), down = parseDownReceipt(JSON.parse(downBytes.toString("utf8"))), expectedCheckpoint = { version: "spawnfile.canary-teardown-checkpoint.v1", ...checkpointBase, down_receipt_sha256: sha(downBytes) }; if (down.deployment !== input.fromDeployment || down.errors.length !== 0 || canonical(JSON.parse(await readFile(teardownCheckpointPath, "utf8"))) !== canonical(expectedCheckpoint)) throw new SpawnfileError("validation_error", "Canary teardown checkpoint does not match the recovered transaction"); } + const checkpointBytes = await readFile(teardownCheckpointPath), downBytes = await readFile(teardownReceiptPath), decision = { version: "spawnfile.canary-decision-receipt.v1", decision: "cutover", nonce: operationNonce, target_run_id: expected.run_id, deployment_mode: expected.deployment_mode, target_identity_sha256: sha(canonical(expected)), ingress_receipt_sha256: sha(ingressBytes), teardown_checkpoint_sha256: sha(checkpointBytes), down_receipt_sha256: sha(downBytes) }; + if (!await exists(input.decisionReceiptPath)) { await publishExclusive(input.decisionReceiptPath, decision); return decision; } + const priorDecision = decisionReceiptSchema.parse(JSON.parse(await readFile(input.decisionReceiptPath, "utf8"))); if (canonical(priorDecision) !== canonical(decision)) throw new SpawnfileError("validation_error", "Canary decision receipt does not match the recovered transaction"); return priorDecision; +}; + +export const verifyEquivalentRollbackReadiness = async (rollbackReadinessPath: string, expectedIdentity: unknown): Promise => { const expected = deploymentIdentitySchema.parse(expectedIdentity), rollback = parseUpReceipt(JSON.parse(await readFile(rollbackReadinessPath, "utf8"))); verifyIdentity(rollback, expected); }; diff --git a/src/deployment/dockerManager.ts b/src/deployment/dockerManager.ts index 061aae1c..3162cce0 100644 --- a/src/deployment/dockerManager.ts +++ b/src/deployment/dockerManager.ts @@ -289,7 +289,7 @@ export const probeDockerOrganizationReadiness = async ( timeoutMs: input.timeoutMs }); const inspection = await gateway.inspectUnit(); - if (input.evidence.hasExternalMoltnet || !input.evidence.worldBindings) { + if (input.evidence.hasExternalMoltnet) { return reconcileOrganizationReadiness({ evidence: input.evidence, inspection, probe: null, record: correlation }); @@ -318,17 +318,23 @@ export const probeDockerOrganizationReadiness = async ( }); } const configs = new Map(); + const attachmentReceipts = new Map(); for (const network of input.evidence.networks) { for (const node of network.nodes) { const result = await gateway.exec(["cat", node.configPath]); configs.set(node.configPath, result.stdout); + const receipt = await gateway.exec(["cat", node.receiptPath]); + attachmentReceipts.set(node.receiptPath, receipt.stdout); } } - const bindings = await gateway.exec(["cat", input.evidence.worldBindings.artifactPath]); + const daimonReceipt = input.evidence.daimon == null ? null : (await gateway.exec(["cat", input.evidence.daimon.receiptPath])).stdout; + const bindings = input.evidence.worldBindings === null + ? null + : await gateway.exec(["cat", input.evidence.worldBindings.artifactPath]); return reconcileOrganizationReadiness({ evidence: input.evidence, inspection, - probe: { configs, networks, worldBindings: bindings.stdout }, + probe: { attachmentReceipts, configs, daimonReceipt, networks, worldBindings: bindings?.stdout ?? null }, record: correlation }); } catch (error) { diff --git a/src/deployment/index.ts b/src/deployment/index.ts index 83ac3e83..4ba77461 100644 --- a/src/deployment/index.ts +++ b/src/deployment/index.ts @@ -22,6 +22,8 @@ export * from "./organizationHandoffTypes.js"; export * from "./organizationHandoffAuthorityTypes.js"; export * from "./organizationHandoffAuthorityStore.js"; export * from "./organizationReady.js"; +export * from "./canaryCutover.js"; +export * from "./productStateClone.js"; export * from "./record.js"; // Compatibility target exports remain available from this legacy deployment barrel. export * from "./target.js"; diff --git a/src/deployment/organizationReady.test.ts b/src/deployment/organizationReady.test.ts index b22e2c46..1f0e3f5e 100644 --- a/src/deployment/organizationReady.test.ts +++ b/src/deployment/organizationReady.test.ts @@ -41,6 +41,7 @@ const evidence: OrganizationReadinessEvidence = { mode: "managed", nodes: [{ configPath: "/var/lib/spawnfile/moltnet/nodes/pitch-alpha.json", + receiptPath: "/run/spawnfile/moltnet-readiness/pitch-alpha.json", memberId: "alpha", nodeId: "agent:alpha", sha256: digest(config) @@ -91,6 +92,7 @@ const inspection = (): DockerUnitInspection => ({ }); const probe = () => ({ + attachmentReceipts: new Map([[evidence.networks[0]!.nodes[0]!.receiptPath, JSON.stringify({ version: "moltnet.node-readiness.v1", attachments: [{ network_id: "pitch", agent_id: "alpha" }] })]]), configs: new Map([[evidence.networks[0]!.nodes[0]!.configPath, config]]), networks: [{ healthOk: true, id: "pitch" }], worldBindings: bindings @@ -119,6 +121,15 @@ const dockerRecord = (): DeploymentRecord => ({ }); describe("organization readiness reconciliation", () => { + it("requires an exact Daimon engine receipt for no-world readiness", () => { + const noWorld = { ...evidence, worldBindings: null, daimon: { receiptPath: "/state/readiness.json", agents: [{ agentId: "agent:alpha", engine: "codex" }] } }; + const baseProbe = { attachmentReceipts: probe().attachmentReceipts, configs: probe().configs, networks: probe().networks, worldBindings: null }; + const valid = JSON.stringify({ version: "noopolis.daimon.readiness-receipt.v1", agents: [{ agent_id: "agent:alpha", engine: "codex" }] }); + expect(reconcileOrganizationReadiness({ evidence: noWorld, inspection: inspection(), record, probe: { ...baseProbe, daimonReceipt: valid } }).state).toBe("ready"); + for (const daimonReceipt of [null, "{", JSON.stringify({ version: "bad", agents: [] }), JSON.stringify({ version: "noopolis.daimon.readiness-receipt.v1", agents: [{}] }), JSON.stringify({ version: "noopolis.daimon.readiness-receipt.v1", agents: [{ agent_id: "agent:alpha", engine: "grok" }] })]) { + expect(reconcileOrganizationReadiness({ evidence: noWorld, inspection: inspection(), record, probe: { ...baseProbe, daimonReceipt } }).code).toBe("topology_mismatch"); + } + }); it("reconciles a healthy never-restarted real-shaped Docker inspection as ready", async () => { const labels = { [dockerDeploymentLabelKeys.compileFingerprint]: evidence.compileFingerprint, @@ -179,6 +190,8 @@ describe("organization readiness reconciliation", () => { { label: "unhealthy Moltnet", inspection: inspection(), probe: { ...probe(), networks: [{ ...probe().networks[0]!, healthOk: false }] }, state: "failed" }, { label: "wrong network", inspection: inspection(), probe: { ...probe(), networks: [{ id: "wrong", healthOk: true }] }, state: "failed" }, { label: "malformed config", inspection: inspection(), probe: { ...probe(), configs: new Map() }, state: "failed" }, + { label: "missing live attachment receipt", inspection: inspection(), probe: { ...probe(), attachmentReceipts: new Map() }, state: "failed" }, + { label: "wrong authenticated attachment", inspection: inspection(), probe: { ...probe(), attachmentReceipts: new Map([[evidence.networks[0]!.nodes[0]!.receiptPath, JSON.stringify({ version: "moltnet.node-readiness.v1", attachments: [{ network_id: "pitch", agent_id: "bravo" }] })]]) }, state: "failed" }, { label: "wrong-version config", inspection: inspection(), probe: { ...probe(), configs: new Map([[evidence.networks[0]!.nodes[0]!.configPath, JSON.stringify({ version: "moltnet.node.v0" })]]) }, state: "failed" }, { label: "wrong binding", inspection: inspection(), probe: { ...probe(), worldBindings: bindings.replace("alpha", "bravo") }, state: "failed" }, { label: "duplicate binding", inspection: inspection(), probe: { ...probe(), worldBindings: bindings.replace("}],\"schema\"", "},{\"member\":{\"id\":\"alpha\"}}],\"schema\"") }, state: "failed" } diff --git a/src/deployment/organizationReady.ts b/src/deployment/organizationReady.ts index 10426014..d17d875d 100644 --- a/src/deployment/organizationReady.ts +++ b/src/deployment/organizationReady.ts @@ -49,7 +49,9 @@ interface ExactOrganizationReadinessInput { } export interface OrganizationReadinessProbeData { + readonly daimonReceipt?: string | null; readonly configs: ReadonlyMap; + readonly attachmentReceipts?: ReadonlyMap; readonly networks: readonly { readonly healthOk: boolean; readonly id: string; @@ -109,8 +111,8 @@ const organizationReadinessObjectSchema = z.object({ if (!validStateCode(result.state, result.code)) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["state"], message: "invalid state/code combination" }); } - if (result.state === "ready" && (result.run_id === null || result.world_binding_digest === null)) { - context.addIssue({ code: z.ZodIssueCode.custom, path: ["state"], message: "ready requires run_id and world_binding_digest" }); + if (result.state === "ready" && result.run_id === null) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["state"], message: "ready requires run_id" }); } }); @@ -215,6 +217,7 @@ const validBindings = ( evidence: OrganizationReadinessEvidence, content: string | null ): boolean => { + if (evidence.worldBindings === null) return content === null; if (!content || !evidence.worldBindings || hash(content) !== evidence.worldBindings.digest) return false; try { const body = exact(JSON.parse(content), ["schema", "bindings"]); @@ -235,12 +238,32 @@ const validBindings = ( } }; +const validDaimon = (evidence: OrganizationReadinessEvidence, content: string | null | undefined): boolean => { + if (evidence.daimon == null) return content == null; + try { + const value = exact(JSON.parse(content ?? ""), ["version", "agents"]); if (value?.version !== "noopolis.daimon.readiness-receipt.v1" || !Array.isArray(value.agents)) return false; + const agents = value.agents.map((entry) => { const item = exact(entry, ["agent_id", "engine"]); return item && typeof item.agent_id === "string" && typeof item.engine === "string" ? [item.agent_id, item.engine] : null; }); + return agents.every(Boolean) && JSON.stringify(agents) === JSON.stringify(evidence.daimon.agents.map((agent) => [agent.agentId, agent.engine])); + } catch { return false; } +}; + +const validAttachmentReceipts = (evidence: OrganizationReadinessEvidence, receipts: ReadonlyMap | undefined): boolean => { + const expected = evidence.networks.flatMap((network) => network.nodes.map((node) => ({ networkId: network.id, memberId: node.memberId, path: node.receiptPath }))); + if (!receipts || receipts.size !== expected.length) return expected.length === 0; + return expected.every((entry) => { + try { + const body = exact(JSON.parse(receipts.get(entry.path) ?? ""), ["version", "attachments"]); if (body?.version !== "moltnet.node-readiness.v1" || !Array.isArray(body.attachments)) return false; + return body.attachments.length === 1 && body.attachments.every((value) => { const attachment = exact(value, ["network_id", "agent_id"]); return attachment?.network_id === entry.networkId && attachment.agent_id === entry.memberId; }); + } catch { return false; } + }); +}; + export const reconcileOrganizationReadiness = ( input: ReconcileOrganizationReadinessInput ): OrganizationReadiness => { const { evidence, inspection, record } = input; if (evidence.hasExternalMoltnet) return correlation(evidence, record, "pending", "external_moltnet"); - if (!evidence.worldBindings || !record.runId) return correlation(evidence, record, "pending", "compiled_evidence_missing"); + if (!record.runId) return correlation(evidence, record, "pending", "compiled_evidence_missing"); if (record.unitCount !== 1 || !inspection || inspection.running !== true || inspection.exists !== true) { return correlation(evidence, record, "pending", "unit_unavailable"); } @@ -253,7 +276,7 @@ export const reconcileOrganizationReadiness = ( || identity.unit !== record.unitId || identity.version !== evidence.compileVersion) { return correlation(evidence, record, "failed", "identity_mismatch"); } - if (!input.probe || input.probe.networks.length !== evidence.networks.length + if (!input.probe || !validDaimon(evidence, input.probe.daimonReceipt) || !validAttachmentReceipts(evidence, input.probe.attachmentReceipts) || input.probe.networks.length !== evidence.networks.length || !evidence.networks.every((network) => { const live = input.probe?.networks.find((candidate) => candidate.id === network.id); return live?.healthOk === true; From d46ab2b8d3cf2d7af87932ce87157088bc1f85aa Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:23 +0200 Subject: [PATCH 11/34] fix(deployment): preserve durable lifecycle completion --- src/cli/downCommand.ts | 17 +---- src/deployment/downDeployment.test.ts | 72 +++++++++++++++++- src/deployment/downDeployment.ts | 76 +++++++++++++++++-- src/deployment/lifecycleCompletion.test.ts | 30 ++------ .../lifecycleCompletionPublication.ts | 21 +++-- src/deployment/lifecycleCompletionStore.ts | 14 ++-- src/deployment/record.test.ts | 2 +- src/deployment/upReceiptTypes.test.ts | 1 - 8 files changed, 178 insertions(+), 55 deletions(-) diff --git a/src/cli/downCommand.ts b/src/cli/downCommand.ts index 5170def2..e3fff78b 100644 --- a/src/cli/downCommand.ts +++ b/src/cli/downCommand.ts @@ -5,7 +5,7 @@ import type { Command } from "commander"; import { createDeploymentLifecycleCorrelation, findLifecycleInvocation, - readDeploymentRecordFromOutput, + readCanonicalDownRecord, type DeploymentLifecycleCorrelation, type DownReceipt, type LifecycleInvocation, @@ -144,10 +144,7 @@ export const registerDownCommand = ( expected?: DeploymentLifecycleCorrelation, ): Promise => { const expectedUnits = expected - ? (await readDeploymentRecordFromOutput( - compiled, - options.deployment, - )).units.map((unit) => unit.id).sort() + ? (await readCanonicalDownRecord(compiled, options.deployment)).record.units.map((unit) => unit.id).sort() : undefined; const receipt: DownReceipt = await handlers.downDeployment({ compiledOutputDirectory: compiled, @@ -186,10 +183,7 @@ export const registerDownCommand = ( exact = stored; correlation = correlationFrom(stored); } else { - const record = await readDeploymentRecordFromOutput( - compiled, - options.deployment, - ); + const record = (await readCanonicalDownRecord(compiled, options.deployment)).record; correlation = createDeploymentLifecycleCorrelation(record); exact = createDownLifecycleInvocation( options.lifecycleInvocation, @@ -204,10 +198,7 @@ export const registerDownCommand = ( async () => { try { const current = createDeploymentLifecycleCorrelation( - await readDeploymentRecordFromOutput( - compiled, - options.deployment, - ), + (await readCanonicalDownRecord(compiled, options.deployment)).record, ); return canonicalLifecycleJson(current) === canonicalLifecycleJson(correlation) diff --git a/src/deployment/downDeployment.test.ts b/src/deployment/downDeployment.test.ts index b0ad400e..80661b4e 100644 --- a/src/deployment/downDeployment.test.ts +++ b/src/deployment/downDeployment.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -20,8 +20,11 @@ import { type OrganizationHandoffAuthorityStore } from "./organizationHandoffAuthorityStore.js"; import { readDeploymentRecord, writeDeploymentRecord, type DeploymentRecord } from "./record.js"; +import { resolveHomeReportPath, writeHomeDeployment } from "./homeStore.js"; +import { buildDistributionReport } from "../distribution/index.js"; const temporaryDirectories: string[] = []; +const originalSpawnfileHome = process.env.SPAWNFILE_HOME; const createTempDirectory = async (prefix: string): Promise => { const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); @@ -30,6 +33,8 @@ const createTempDirectory = async (prefix: string): Promise => { }; afterEach(async () => { + if (originalSpawnfileHome === undefined) delete process.env.SPAWNFILE_HOME; + else process.env.SPAWNFILE_HOME = originalSpawnfileHome; await Promise.all(temporaryDirectories.splice(0).map((directory) => removeDirectory(directory))); vi.mocked(initializeOrganizationHandoffAuthorityStore).mockReset(); }); @@ -165,6 +170,71 @@ const setupCompiledOutput = async ( }; describe("downDeployment", () => { + it("routes an image home record through exact identity teardown and retains its realms", async () => { + process.env.SPAWNFILE_HOME = await createTempDirectory("spawnfile-image-down-home-"); + const compiledOutputDirectory = await createTempDirectory("spawnfile-image-down-project-"); + const containerId = "a".repeat(64), volumeCalls: string[][] = []; + const distribution = buildDistributionReport({ envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], modelAuthMethods: {}, moltnetNetworks: [], organization: { agents: [], project: "image", teams: [] }, persistentMounts: [{ durability: "persistent", id: "realm", kind: "volume", lifecycle: "exclusive-reattach", target: "/realm" }], portMappings: [], publishedPorts: [], resources: [], runtimeInstances: [] }); + const imageRecord = createRecord({ compile_fingerprint: distribution.compile_fingerprint, name: "image", output_directory: null, source: { digest: null, kind: "image", ref: "org/image:v1" }, units: [{ ...createRecord().units[0]!, container_id: containerId, container_name: "spawnfile-image", id: "image-container" }] }); + await writeHomeDeployment(imageRecord, distribution); + const execFile = async (_file: string, args: string[]) => { volumeCalls.push(args); if (args.includes("inspect")) return { stderr: "", stdout: `${JSON.stringify(containerId)}\n${JSON.stringify("/spawnfile-image")}\n${JSON.stringify("image-123")}\n${JSON.stringify({ "com.spawnfile.deployment": "image", "com.spawnfile.compile_fingerprint": distribution.compile_fingerprint, "com.spawnfile.unit": "image-container" })}\n` }; return { stderr: "", stdout: "" }; }; + const receipt = await downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile, force: true }); + expect(receipt.units_stopped).toEqual(["image-container"]); + expect(receipt.retained_volumes[0]).toMatch(/^spawnfile-exclusive-realm-/u); + expect(volumeCalls.some((args) => args.includes("volume"))).toBe(false); + const removed = await downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile, force: true, removeVolumes: true }); + expect(removed.retained_volumes).toEqual([]); + expect(volumeCalls.some((args) => args.includes("volume") && args.includes("rm"))).toBe(true); + }); + + it("removes image volumes only explicitly and rejects foreign identity", async () => { + process.env.SPAWNFILE_HOME = await createTempDirectory("spawnfile-image-down-home-"); + const compiledOutputDirectory = await createTempDirectory("spawnfile-image-down-project-"); + const containerId = "b".repeat(64); + const distribution = buildDistributionReport({ envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], modelAuthMethods: {}, moltnetNetworks: [], organization: { agents: [], project: "image", teams: [] }, persistentMounts: [{ durability: "persistent", id: "store", kind: "volume", target: "/store" }], portMappings: [], publishedPorts: [], resources: [], runtimeInstances: [] }); + const imageRecord = createRecord({ compile_fingerprint: distribution.compile_fingerprint, name: "image", output_directory: null, source: { digest: null, kind: "image", ref: "org/image:v1" }, units: [{ ...createRecord().units[0]!, container_id: containerId, container_name: "spawnfile-image", id: "image-container" }] }); + await writeHomeDeployment(imageRecord, distribution); + const foreign = async () => ({ stderr: "", stdout: `${JSON.stringify(containerId)}\n${JSON.stringify("/foreign")}\n${JSON.stringify("image-123")}\n${JSON.stringify({})}\n` }); + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile: foreign, force: true, removeVolumes: true })).rejects.toThrow("does not match"); + }); + + it("rejects missing and mismatched image identities before mutation", async () => { + process.env.SPAWNFILE_HOME = await createTempDirectory("spawnfile-image-down-home-"); + const compiledOutputDirectory = await createTempDirectory("spawnfile-image-down-project-"); + const distribution = buildDistributionReport({ envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], modelAuthMethods: {}, moltnetNetworks: [], organization: { agents: [], project: "image", teams: [] }, persistentMounts: [], portMappings: [], publishedPorts: [], resources: [], runtimeInstances: [] }); + const containerId = "f".repeat(64), calls: string[][] = []; + const base = createRecord({ compile_fingerprint: distribution.compile_fingerprint, name: "image", output_directory: null, source: { digest: null, kind: "image", ref: "org/image:v1" }, units: [{ ...createRecord().units[0]!, container_id: containerId, container_name: "spawnfile-image", id: "image-container" }] }); + await writeHomeDeployment({ ...base, units: [{ ...base.units[0]!, image_id: null }] }, distribution); + const execFile = async (_file: string, args: string[]) => { calls.push(args); return { stderr: "", stdout: `${JSON.stringify(containerId)}\n${JSON.stringify("/spawnfile-image")}\n${JSON.stringify("foreign-image")}\n${JSON.stringify({ "com.spawnfile.deployment": "image", "com.spawnfile.compile_fingerprint": distribution.compile_fingerprint, "com.spawnfile.unit": "image-container" })}\n` }; }; + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile, force: true, removeVolumes: true })).rejects.toThrow("image identity"); + expect(calls).toEqual([]); + await writeHomeDeployment(base, distribution); + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile, force: true, removeVolumes: true })).rejects.toThrow("does not match"); + expect(calls).toHaveLength(1); + expect(calls.some((args) => args.includes("rm"))).toBe(false); + }); + + it("fails closed when project and image records are both canonical candidates", async () => { + process.env.SPAWNFILE_HOME = await createTempDirectory("spawnfile-image-down-home-"); + const compiledOutputDirectory = await setupCompiledOutput({ export_index: exported }); + const imageRecord = createRecord({ output_directory: null, source: { digest: null, kind: "image", ref: "org/image:v1" } }); + await writeHomeDeployment(imageRecord, buildDistributionReport({ envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], modelAuthMethods: {}, moltnetNetworks: [], organization: { agents: [], project: "image", teams: [] }, persistentMounts: [], portMappings: [], publishedPorts: [], resources: [], runtimeInstances: [] })); + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "default", execFile: createFakeExecFile(), force: true })).rejects.toThrow("ambiguous"); + }); + + it("rejects missing records and a tampered cached image report before Docker mutation", async () => { + process.env.SPAWNFILE_HOME = await createTempDirectory("spawnfile-image-down-home-"); + const compiledOutputDirectory = await createTempDirectory("spawnfile-image-down-project-"); + const execFile = vi.fn(async () => ({ stderr: "", stdout: "" })); + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "missing", execFile, force: true })).rejects.toThrow("No recorded deployment"); + const distribution = buildDistributionReport({ envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], modelAuthMethods: {}, moltnetNetworks: [], organization: { agents: [], project: "image", teams: [] }, persistentMounts: [], portMappings: [], publishedPorts: [], resources: [], runtimeInstances: [] }); + const containerId = "e".repeat(64); + await writeHomeDeployment(createRecord({ compile_fingerprint: distribution.compile_fingerprint, name: "image", output_directory: null, source: { digest: null, kind: "image", ref: "org/image:v1" }, units: [{ ...createRecord().units[0]!, container_id: containerId, container_name: "spawnfile-image", id: "image-container" }] }), distribution); + await writeFile(resolveHomeReportPath("image"), "{}\n"); + execFile.mockResolvedValueOnce({ stderr: "", stdout: `${JSON.stringify(containerId)}\n${JSON.stringify("/spawnfile-image")}\n${JSON.stringify("image-123")}\n${JSON.stringify({ "com.spawnfile.deployment": "image", "com.spawnfile.compile_fingerprint": distribution.compile_fingerprint, "com.spawnfile.unit": "image-container" })}\n` }); + await expect(downDeployment({ compiledOutputDirectory, deploymentName: "image", execFile, force: true })).rejects.toThrow(); + expect(execFile).toHaveBeenCalledTimes(1); + }); it("leaves ordinary records on the existing down path without initializing authority", async () => { const compiledOutputDirectory = await setupCompiledOutput({ export_index: exported }); diff --git a/src/deployment/downDeployment.ts b/src/deployment/downDeployment.ts index 4e60fd5c..cf44ced2 100644 --- a/src/deployment/downDeployment.ts +++ b/src/deployment/downDeployment.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileExists, readUtf8File } from "../filesystem/index.js"; +import { derivePersistentMountVolumeName, parseDistributionReport } from "../distribution/index.js"; import type { CompileReport } from "../report/index.js"; import { REPORT_FILENAME, SpawnfileError } from "../shared/index.js"; @@ -22,6 +23,9 @@ import { } from "./organizationHandoffAuthorityStore.js"; import { readDeploymentRecordFromOutput } from "./record.js"; import type { DeploymentRecord } from "./record.js"; +import { readHomeDeploymentRecord, readHomeDeploymentReport, resolveHomeRecordPath } from "./homeStore.js"; +import { resolveDeploymentRecordPath } from "./names.js"; +import { dockerContextNameForTarget } from "./target.js"; export interface DownDeploymentOptions { /** Where the deployment record + compile report already live (the compile's own @@ -46,6 +50,69 @@ export interface DownDeploymentOptions { timeoutMs?: number; } +export const readCanonicalDownRecord = async ( + compiledOutputDirectory: string, + deploymentName: string +): Promise<{ mode: "image" | "project"; record: DeploymentRecord }> => { + const projectPath = resolveDeploymentRecordPath(path.resolve(compiledOutputDirectory), deploymentName); + const homePath = resolveHomeRecordPath(deploymentName); + const [projectExists, homeExists] = await Promise.all([fileExists(projectPath), fileExists(homePath)]); + if (projectExists && homeExists) throw new SpawnfileError("runtime_error", "Deployment record is ambiguous between project and image stores"); + if (projectExists) { + const record = await readDeploymentRecordFromOutput(path.resolve(compiledOutputDirectory), deploymentName); + if (record.source.kind !== "project") throw new SpawnfileError("runtime_error", "Project deployment store contains a non-project record"); + return { mode: "project", record }; + } + if (homeExists) { + const record = await readHomeDeploymentRecord(deploymentName); + if (record.source.kind !== "image") throw new SpawnfileError("runtime_error", "Image deployment store contains a non-image record"); + return { mode: "image", record }; + } + throw new SpawnfileError("validation_error", `No recorded deployment named ${deploymentName}`); +}; + +const imageDown = async ( + record: DeploymentRecord, + options: DownDeploymentOptions, + runner: { dockerCommand: string; execFile: DockerTeardownExecFile; timeoutMs: number } +): Promise => { + if (record.source.kind !== "image" || record.units.length !== 1) throw new SpawnfileError("runtime_error", "Image deployment record is invalid for teardown"); + if (!record.export_index && !options.force) throw new SpawnfileError("runtime_error", "Image deployment artifact export is unsupported; pass --force to discard unexported artifacts"); + const unit = record.units[0]!; + if (!unit.container_id || !unit.image_id) throw new SpawnfileError("runtime_error", "Image deployment has no exact recorded container or image identity"); + const context = dockerContextNameForTarget(record.target); + const prefix = context ? ["--context", context] : record.target.kind === "host" ? ["--host", record.target.value] : []; + const inspected = await runner.execFile(runner.dockerCommand, [...prefix, "container", "inspect", "--format", "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}", unit.container_id], { timeout: runner.timeoutMs }); + let identity: { id: string; imageId: string; labels: Record; name: string }; + try { + const [idLine, nameLine, imageLine, labelsLine, ...extra] = inspected.stdout.trim().split("\n"); + if (!idLine || !nameLine || !imageLine || !labelsLine || extra.length) throw new Error(); + identity = { id: JSON.parse(idLine), imageId: JSON.parse(imageLine), name: JSON.parse(nameLine), labels: JSON.parse(labelsLine) }; + } catch { throw new SpawnfileError("runtime_error", "Image deployment container identity is malformed"); } + if (identity.id !== unit.container_id || identity.name !== `/${unit.container_name}` || identity.imageId !== unit.image_id + || identity.labels["com.spawnfile.deployment"] !== record.name + || identity.labels["com.spawnfile.compile_fingerprint"] !== record.compile_fingerprint + || identity.labels["com.spawnfile.unit"] !== unit.id) { + throw new SpawnfileError("runtime_error", "Image deployment container identity does not match its record"); + } + let reportValue: unknown; + try { + reportValue = JSON.parse(await readHomeDeploymentReport(record.name)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new SpawnfileError("runtime_error", `Cached image distribution report is unreadable: ${reason}`); + } + const report = parseDistributionReport(reportValue, "cached image distribution report"); + if (report.compile_fingerprint !== record.compile_fingerprint) throw new SpawnfileError("runtime_error", "Cached image distribution report does not match its deployment record"); + const volumes = [...new Set(report.persistent_mounts.map((mount) => derivePersistentMountVolumeName(record.name, mount)))].sort(); + const removed = await removeDockerContainer(record.target, unit.container_id, runner); + if (!removed.removed) return { deployment: record.name, errors: [`unable to remove container ${unit.container_id} (unit ${unit.id}): ${removed.error}`], retained_volumes: volumes, units_stopped: [], version: DOWN_RECEIPT_VERSION }; + const errors: string[] = [], retained: string[] = []; + if (options.removeVolumes) for (const volume of volumes) { const result = await removeDockerVolume(record.target, volume, runner); if (!result.removed) { errors.push(`unable to remove volume ${volume}: ${result.error}`); retained.push(volume); } } + else retained.push(...volumes); + return { deployment: record.name, errors, retained_volumes: retained, units_stopped: [unit.id], version: DOWN_RECEIPT_VERSION }; +}; + const targetRefForUnit = ( unit: DeploymentRecord["units"][number], ): string | null => unit.container_id ?? unit.container_name; @@ -165,14 +232,14 @@ export const downDeployment = async ( const execFileImpl = options.execFile ?? defaultDockerTeardownExecFile; const timeoutMs = options.timeoutMs ?? 30_000; - const initialRecord = await readDeploymentRecordFromOutput( - compiledOutputDirectory, - options.deploymentName, - ); + const resolved = await readCanonicalDownRecord(compiledOutputDirectory, options.deploymentName); + const initialRecord = resolved.record; assertDeploymentLifecycleCorrelation( initialRecord, options.expectedLifecycleCorrelation, ); + const runnerOptions = { dockerCommand, execFile: execFileImpl, timeoutMs }; + if (resolved.mode === "image") return imageDown(initialRecord, options, runnerOptions); const record = await ensureExported(initialRecord, { ...options, compiledOutputDirectory, @@ -183,7 +250,6 @@ export const downDeployment = async ( ); await closeOrganizationHandoff(record); - const runnerOptions = { dockerCommand, execFile: execFileImpl, timeoutMs }; const unitsStopped: string[] = []; const errors: string[] = []; diff --git a/src/deployment/lifecycleCompletion.test.ts b/src/deployment/lifecycleCompletion.test.ts index ac35a94a..bc258041 100644 --- a/src/deployment/lifecycleCompletion.test.ts +++ b/src/deployment/lifecycleCompletion.test.ts @@ -36,7 +36,6 @@ import { setLifecycleStoreTestHook } from "./lifecycleCompletionStore.js"; import { matchesSettledLifecyclePublication } from "./lifecycleCompletionPublication.js"; const originalHome = process.env.SPAWNFILE_HOME; -const originalSetImmediate = globalThis.setImmediate; let home = ""; const invocation = ( @@ -71,7 +70,6 @@ beforeEach(async () => { afterEach(async () => { setLifecycleStoreTestHook(null); - globalThis.setImmediate = originalSetImmediate; if (originalHome === undefined) delete process.env.SPAWNFILE_HOME; else process.env.SPAWNFILE_HOME = originalHome; await rm(home, { force: true, recursive: true }); @@ -486,21 +484,13 @@ describe("lifecycle completion store", () => { // This deliberately exceeds the former short settle window. A real // competing publisher can be delayed by unrelated lifecycle work, but its // exact temporary link must still settle before a second claimant fails. - const delayedUnlinkYields = 20; - let yields = 0; - globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { - yields += 1; - if (yields === delayedUnlinkYields) { - void rm(copy).then(() => callback()); - } else { - originalSetImmediate(callback); - } - return {} as NodeJS.Immediate; - }) as typeof setImmediate; + const delayedUnlink = new Promise((resolve, reject) => { + setTimeout(() => rm(copy).then(resolve, reject), 200); + }); await expect(claimLifecycleInvocation(transient)).resolves.toMatchObject({ status: "pending", }); - expect(yields).toBeGreaterThanOrEqual(delayedUnlinkYields); + await delayedUnlink; const bytes = JSON.stringify( { @@ -526,20 +516,16 @@ describe("lifecycle completion store", () => { await writeFile(file, content, { mode: 0o600 }); await link(file, copy); let reads = 0; - let yields = 0; - globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { - yields += 1; - if (yields === 3) void rm(copy).then(() => callback()); - else originalSetImmediate(callback); - return {} as NodeJS.Immediate; - }) as typeof setImmediate; + const delayedUnlink = new Promise((resolve, reject) => { + setTimeout(() => rm(copy).then(resolve, reject), 20); + }); await expect( matchesSettledLifecyclePublication(file, content, async (candidate) => { reads += 1; return readFile(candidate, "utf8"); }), ).resolves.toBe(true); - expect(yields).toBeGreaterThanOrEqual(3); + await delayedUnlink; expect(reads).toBe(2); }); diff --git a/src/deployment/lifecycleCompletionPublication.ts b/src/deployment/lifecycleCompletionPublication.ts index 2a45727d..9018d40d 100644 --- a/src/deployment/lifecycleCompletionPublication.ts +++ b/src/deployment/lifecycleCompletionPublication.ts @@ -1,4 +1,5 @@ import { lstat } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; import { SpawnfileError } from "../shared/index.js"; @@ -11,7 +12,8 @@ type RecordReader = ( // short run of event-loop turns can elapse before that unlink is scheduled // when many lifecycle operations are active, so leave a bounded but practical // window before treating an extra link as hostile. -export const LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS = 64; +export const LIFECYCLE_PUBLICATION_SETTLE_TIMEOUT_MS = 1_000; +const LIFECYCLE_PUBLICATION_SETTLE_BACKOFF_MS = 2; const refuse = (message: string): never => { throw new SpawnfileError( @@ -23,12 +25,21 @@ const refuse = (message: string): never => { export const settleLifecyclePublication = async ( file: string, ): Promise => { - for (let attempt = 0; attempt < LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS; attempt += 1) { + await settleLifecyclePublicationUntil(async () => { const info = await lstat(file).catch(() => refuse("publication changed")); - if (info.nlink === 1) return; - await new Promise((resolve) => setImmediate(resolve)); + return info.nlink === 1; + }); +}; + +export const settleLifecyclePublicationUntil = async ( + settled: () => Promise, +): Promise => { + const deadline = performance.now() + LIFECYCLE_PUBLICATION_SETTLE_TIMEOUT_MS; + while (true) { + if (await settled()) return; + if (performance.now() >= deadline) refuse("publication did not settle"); + await new Promise((resolve) => setTimeout(resolve, LIFECYCLE_PUBLICATION_SETTLE_BACKOFF_MS)); } - refuse("publication did not settle"); }; export const readSettledLifecycleRecord = async ( diff --git a/src/deployment/lifecycleCompletionStore.ts b/src/deployment/lifecycleCompletionStore.ts index bf07aba8..dd06e934 100644 --- a/src/deployment/lifecycleCompletionStore.ts +++ b/src/deployment/lifecycleCompletionStore.ts @@ -18,9 +18,9 @@ import { type LifecycleRootAuthority } from "./lifecycleCompletionRoot.js"; import { - LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS, matchesSettledLifecyclePublication, - readSettledLifecycleRecord + readSettledLifecycleRecord, + settleLifecyclePublicationUntil } from "./lifecycleCompletionPublication.js"; export { @@ -183,16 +183,16 @@ export const publishLifecycleRecord = async ( } return false; } - for (let attempt = 0; attempt < LIFECYCLE_PUBLICATION_SETTLE_ATTEMPTS; attempt += 1) { + await settleLifecyclePublicationUntil(async () => { const exact = await readLifecycleRecord(final, [1, 2]); if (exact !== content) failLifecycleStore("publication changed"); if ((await lstat(final).catch(() => null))?.nlink === 1) { await revalidateHeldLifecycleRoot(authority, rootHandle); - return linked; + return true; } - await new Promise((resolve) => setImmediate(resolve)); - } - return failLifecycleStore("publication did not settle"); + return false; + }); + return linked; } finally { await rootHandle.close().catch(() => undefined); } diff --git a/src/deployment/record.test.ts b/src/deployment/record.test.ts index c7992a2d..141de1d1 100644 --- a/src/deployment/record.test.ts +++ b/src/deployment/record.test.ts @@ -130,10 +130,10 @@ describe("deployment records", () => { { ...organizationReadiness, run_id: "bad/run" }, { ...organizationReadiness, world_binding_digest: "sha256:bad" }, { ...organizationReadiness, run_id: null }, - { ...organizationReadiness, world_binding_digest: null }, prototypeBearing ]; expect(deploymentRecordSchema.safeParse(readyRecord).success).toBe(true); + expect(deploymentRecordSchema.safeParse({ ...readyRecord, organization_ready: { ...organizationReadiness, world_binding_digest: null } }).success).toBe(true); for (const organization_ready of hostileValues) { expect(deploymentRecordSchema.safeParse({ ...createRecord(outputDirectory), organization_ready }).success) .toBe(false); diff --git a/src/deployment/upReceiptTypes.test.ts b/src/deployment/upReceiptTypes.test.ts index 3dafe7e3..18d291a0 100644 --- a/src/deployment/upReceiptTypes.test.ts +++ b/src/deployment/upReceiptTypes.test.ts @@ -141,7 +141,6 @@ describe("upReceiptSchema / parseUpReceipt", () => { { ...organizationReadiness, run_id: "bad/run" }, { ...organizationReadiness, world_binding_digest: "sha256:bad" }, { ...organizationReadiness, run_id: null }, - { ...organizationReadiness, world_binding_digest: null }, prototypeBearing ]) { const receipt = { ...createReceipt(), organization_ready }; From 3ae62e59cc388410eb9b34ef2baca3d9a6c32133 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:27 +0200 Subject: [PATCH 12/34] feat(distribution): reattach consumed runtime state --- src/distribution/consumeImage.test.ts | 328 ++++++++++++++++-- src/distribution/consumeImage.ts | 172 ++++----- .../consumeImageLifecycle.test.ts | 231 ++++++++++++ src/distribution/consumeImageLifecycle.ts | 278 +++++++++++++++ src/distribution/consumeImageSupport.test.ts | 30 ++ src/distribution/consumeImageSupport.ts | 9 + .../distributionReportSchema.test.ts | 47 +++ src/distribution/distributionReportSchema.ts | 8 +- src/distribution/extractImage.test.ts | 24 ++ src/distribution/imageRuntimeAuth.test.ts | 76 +++- src/distribution/imageRuntimeAuth.ts | 81 ++++- src/distribution/index.ts | 1 + src/distribution/types.ts | 9 +- 13 files changed, 1172 insertions(+), 122 deletions(-) create mode 100644 src/distribution/consumeImageLifecycle.test.ts create mode 100644 src/distribution/consumeImageLifecycle.ts diff --git a/src/distribution/consumeImage.test.ts b/src/distribution/consumeImage.test.ts index 8465287e..f5e82e86 100644 --- a/src/distribution/consumeImage.test.ts +++ b/src/distribution/consumeImage.test.ts @@ -4,17 +4,22 @@ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { readHomeDeploymentRecord, readHomeDeploymentReport } from "../deployment/index.js"; +import { downDeployment, readHomeDeploymentRecord, readHomeDeploymentReport } from "../deployment/index.js"; import { removeDirectory } from "../filesystem/index.js"; import { buildDistributionReport } from "./buildDistributionReport.js"; import { DISTRIBUTION_REPORT_IMAGE_PATH } from "./types.js"; +import type { DistributionPersistentMount } from "./types.js"; import { consumeImageUp } from "./consumeImage.js"; const previousHome = process.env.SPAWNFILE_HOME; +const previousDaimonCodexSource = process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH; +const previousDaimonGrokSource = process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH; let homeDirectory: string; -const report = () => +const report = (persistentMounts: DistributionPersistentMount[] = [ + { durability: "persistent" as const, id: "store", kind: "volume" as const, target: "/var/lib/spawnfile/x" } +]) => buildDistributionReport({ envVariables: [ { categories: ["model"], generated: false, name: "ANTHROPIC_API_KEY", required: true }, @@ -29,9 +34,7 @@ const report = () => project: "distribution-org", teams: [{ agents: ["agent:a"], id: "team:o", name: "distribution-org" }] }, - persistentMounts: [ - { durability: "persistent", id: "store", kind: "volume", target: "/var/lib/spawnfile/x" } - ], + persistentMounts, portMappings: [], publishedPorts: [], resources: [], @@ -51,6 +54,24 @@ const report = () => ] }); +const daimonReport = () => buildDistributionReport({ + envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], + modelAuthMethods: {}, moltnetNetworks: [], + organization: { agents: [ + { id: "agent:coder", name: "coder", runtime: "daimon", teams: [] }, + { id: "agent:reviewer", name: "reviewer", runtime: "daimon", teams: [] } + ], project: "distribution-org", teams: [] }, + persistentMounts: [{ durability: "persistent", id: "grok-realm", kind: "volume", lifecycle: "exclusive-reattach", target: "/var/lib/spawnfile/daimon/grok-subscription-realm" }], + portMappings: [], publishedPorts: [], resources: [], + runtimeInstances: [{ + config_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json", + engine_by_node_id: { "agent:coder": "codex", "agent:reviewer": "grok" }, + home_path: null, id: "daimon-organization", internal_port: null, + model_auth_methods: {}, model_secrets_required: [], node_ids: ["agent:coder", "agent:reviewer"], + published_port: null, runtime: "daimon", workspace_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace" + }] +}); + const buildTar = (content: Buffer): Buffer => { const header = Buffer.alloc(512); header.write("spawnfile-report.json", 0, "ascii"); @@ -65,22 +86,36 @@ interface FakeDockerState { calls: string[][]; } +const candidateContainerId = "c".repeat(64); +const previousContainerId = "d".repeat(64); + const createFakeDocker = ( state: FakeDockerState, customReport?: ReturnType, - options: { liveExists?: boolean } = {} + options: { liveExists?: boolean; runOutput?: string } = {} ) => { const distributionReport = customReport ?? report(); - // Names created (via run/rename-to) and names removed/renamed-away; together - // with options.liveExists these model real container existence across a swap. - const present = new Set(); - const gone = new Set(); + const containers = new Map; name: string; running: boolean; + }>(); + let previousSeeded = false; + let reservationSequence = 0; const labels = { "com.spawnfile.compile_fingerprint": distributionReport.compile_fingerprint, "com.spawnfile.image_contract": "spawnfile.image.v1", "com.spawnfile.project": "distribution-org", "com.spawnfile.report": DISTRIBUTION_REPORT_IMAGE_PATH }; + const find = (reference: string) => [...containers.values()].find( + (container) => container.id === reference || container.name === reference + ); + const seedPrevious = (reference: string) => { + if (!options.liveExists || previousSeeded || !reference.startsWith("spawnfile-")) return; + previousSeeded = true; + containers.set(previousContainerId, { + id: previousContainerId, labels: {}, name: reference, running: true + }); + }; return async (args: string[]): Promise => { state.calls.push(args); if (args[0] === "image" && args[1] === "inspect" && args.includes("{{json .Config.Labels}}")) { @@ -95,37 +130,66 @@ const createFakeDocker = ( if (args[0] === "image" && args[1] === "inspect" && args.includes("{{json .RepoDigests}}")) { return Buffer.from(JSON.stringify(["you/org@sha256:remotedigest"])); } - // Track container existence statefully so the swap/rollback sequence is - // modelled faithfully: a `container inspect` reflects prior rename/run/rm - // calls, not a static flag. `present`/`gone` override the initial liveExists. if (args[0] === "rename") { - gone.add(args[1]!); - gone.delete(args[2]!); - present.add(args[2]!); - present.delete(args[1]!); + const container = find(args[1]!); + if (!container) throw new Error("No such container"); + container.name = args[2]!; return Buffer.from(""); } - if (args[0] === "rm") { - const name = args[args.length - 1]!; - gone.add(name); - present.delete(name); + if (args[0] === "rm" || (args[0] === "container" && args[1] === "rm")) { + const container = find(args[args.length - 1]!); + if (!container) throw new Error("No such container"); + containers.delete(container.id); return Buffer.from(""); } + if (args[0] === "stop" || args[0] === "start") { + const container = find(args[1]!); + if (!container) throw new Error("No such container"); + container.running = args[0] === "start"; + return Buffer.from(""); + } + if (args[0] === "container" && args[1] === "create") { + const name = args[args.indexOf("--name") + 1]!; + if ([...containers.values()].some((container) => container.name === name)) { + throw new Error("Conflict. container name already in use"); + } + const reservationLabels: Record = {}; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== "--label") continue; + const [key, value] = args[index + 1]!.split("=", 2); + reservationLabels[key!] = value!; + } + const id = `${(++reservationSequence).toString(16)}`.padStart(64, "e"); + containers.set(id, { id, labels: reservationLabels, name, running: false }); + return Buffer.from(`${id}\n`); + } if (args[0] === "container" && args[1] === "inspect") { - const name = args[2]!; - const exists = present.has(name) || (Boolean(options.liveExists) && !gone.has(name)); - if (exists) { - return Buffer.from("[{}]"); + const reference = args[args.length - 1]!; + seedPrevious(reference); + const container = find(reference); + if (!container) throw new Error("No such container"); + const format = args[args.indexOf("--format") + 1] ?? ""; + if (format.includes(".Config.Labels")) { + return Buffer.from(`${JSON.stringify(container.id)}\n${JSON.stringify(container.labels)}`); } - throw new Error("No such container"); + if (format.includes("{{json .State}}")) { + return Buffer.from([ + JSON.stringify(container.id), JSON.stringify(`/${container.name}`), + JSON.stringify({ Running: container.running, Status: container.running ? "running" : "exited" }) + ].join("\n")); + } + return Buffer.from([ + JSON.stringify(container.id), JSON.stringify(`/${container.name}`), JSON.stringify(container.running) + ].join("\n")); } if (args[0] === "run") { const nameIndex = args.indexOf("--name"); if (nameIndex >= 0) { - present.add(args[nameIndex + 1]!); - gone.delete(args[nameIndex + 1]!); + containers.set(candidateContainerId, { + id: candidateContainerId, labels: {}, name: args[nameIndex + 1]!, running: true + }); } - return Buffer.from("container-id-123\n"); + return Buffer.from(options.runOutput ?? `${candidateContainerId}\n`); } return Buffer.from(""); }; @@ -142,10 +206,27 @@ afterEach(async () => { } else { process.env.SPAWNFILE_HOME = previousHome; } + if (previousDaimonCodexSource === undefined) delete process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH; + else process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH = previousDaimonCodexSource; + if (previousDaimonGrokSource === undefined) delete process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH; + else process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH = previousDaimonGrokSource; await removeDirectory(homeDirectory).catch(() => undefined); }); describe("consumeImageUp", () => { + it("mounts direct Daimon sources from the embedded report before starting a sourceless image", async () => { + const codex = path.join(homeDirectory, "codex.json"), grok = path.join(homeDirectory, "grok.json"); + await writeFile(codex, JSON.stringify({ tokens: { access_token: "fake-access", refresh_token: "fake-refresh" } }), { mode: 0o600 }); + await writeFile(grok, JSON.stringify({ "https://auth.x.ai::fixture": { key: "a".repeat(32), refresh_token: "r".repeat(16), expires_at: "2099-01-01T00:00:00.000Z" } }), { mode: 0o600 }); + process.env.SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH = codex; + process.env.SPAWNFILE_DAIMON_SOURCE_GROK_AUTH = grok; + const state: FakeDockerState = { calls: [] }; + await consumeImageUp("you/org:v3", { deploymentName: "daimon-direct", runDocker: createFakeDocker(state, daimonReport()) }); + const run = state.calls.find((call) => call[0] === "run")!; + expect(run).toContain(`${codex}:/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/coder/.daimon-inbound/codex-auth:ro`); + expect(run).toContain(`${grok}:/var/lib/spawnfile/daimon/grok-bootstrap-auth:ro`); + }); + it("deploys, writes a v2 image record and cached report", async () => { const state: FakeDockerState = { calls: [] }; const result = await consumeImageUp("you/org@sha256:" + "a".repeat(64), { @@ -158,12 +239,36 @@ describe("consumeImageUp", () => { const record = await readHomeDeploymentRecord("research"); expect(record.version).toBe("spawnfile.deployment.v2"); expect(record.source).toMatchObject({ kind: "image" }); - expect(record.units[0]?.container_id).toBe("container-id-123"); + expect(record.units[0]?.container_id).toBe(candidateContainerId); expect(record.units[0]?.contains).toContainEqual({ id: "dist_lab", kind: "network" }); const cached = JSON.parse(await readHomeDeploymentReport("research")); expect(cached.version).toBe("spawnfile.distribution-report.v1"); }); + it("routes a literal image up record into exact-id down", async () => { + const state: FakeDockerState = { calls: [] }; + await consumeImageUp("you/org@sha256:" + "a".repeat(64), { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "lifecycle", + runDocker: createFakeDocker(state) + }); + const record = await readHomeDeploymentRecord("lifecycle"); + const unit = record.units[0]!; + const calls: string[][] = []; + await downDeployment({ + compiledOutputDirectory: path.join(homeDirectory, "unrelated-project"), + deploymentName: "lifecycle", + force: true, + execFile: async (_file, args) => { + calls.push(args); + if (args.includes("inspect")) return { stderr: "", stdout: `${JSON.stringify(unit.container_id)}\n${JSON.stringify(`/${unit.container_name}`)}\n${JSON.stringify(unit.image_id)}\n${JSON.stringify({ "com.spawnfile.deployment": record.name, "com.spawnfile.compile_fingerprint": record.compile_fingerprint, "com.spawnfile.unit": unit.id })}\n` }; + return { stderr: "", stdout: "" }; + } + }); + expect(calls.some((args) => args.includes("rm") && args.includes(unit.container_id!))).toBe(true); + expect(calls.some((args) => args.includes(unit.container_name!))).toBe(false); + }); + it("uses the pinned digest from a digest-ref directly", async () => { const state: FakeDockerState = { calls: [] }; await consumeImageUp("you/org@sha256:" + "b".repeat(64), { @@ -177,6 +282,33 @@ describe("consumeImageUp", () => { ); }); + it("fails closed when detached run output has no immutable container id", async () => { + const state: FakeDockerState = { calls: [] }; + await expect(consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "empty-run-output", + runDocker: createFakeDocker(state, undefined, { runOutput: "" }) + })).rejects.toThrow(/invalid identity/u); + expect(state.calls.some((call) => call[0] === "rm" && call.includes("spawnfile-empty-run-output"))) + .toBe(false); + }); + + it("records no registry digest when image metadata has no digest-qualified ref", async () => { + const state: FakeDockerState = { calls: [] }; + const base = createFakeDocker(state); + const runDocker = async (args: string[]): Promise => + args[0] === "image" && args[1] === "inspect" && args.includes("{{json .RepoDigests}}") + ? Buffer.from(JSON.stringify(["you/org:1.0.0"])) + : base(args); + await consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "missing-registry-digest", + runDocker + }); + const record = await readHomeDeploymentRecord("missing-registry-digest"); + expect(record.source.kind === "image" && record.source.digest).toBeNull(); + }); + it("derives a deployment-scoped volume mount", async () => { const state: FakeDockerState = { calls: [] }; await consumeImageUp("you/org:1.0.0", { @@ -188,6 +320,44 @@ describe("consumeImageUp", () => { expect(runCall?.join(" ")).toContain("spawnfile_vol_store:/var/lib/spawnfile/x"); }); + it("isolates exclusive realms across deployment lineages", async () => { + const exclusiveReport = report([{ + durability: "persistent", id: "provider-subscription-realm", kind: "volume", + lifecycle: "exclusive-reattach", target: "/var/lib/spawnfile/provider-realm" + }]); + const volumes: string[] = []; + for (const deploymentName of ["blue", "green"]) { + const state: FakeDockerState = { calls: [] }; + await consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName, + runDocker: createFakeDocker(state, exclusiveReport) + }); + const run = state.calls.find((call) => call[0] === "run")!; + volumes.push(run[run.indexOf("-v") + 1]!.split(":")[0]!); + } + expect(volumes[0]).not.toBe(volumes[1]); + expect(volumes[0]).toMatch(/^spawnfile-exclusive-provider-subscription-realm-[a-f0-9]{16}$/u); + }); + + it("refuses concurrent attachment of an exclusive realm by another deployment", async () => { + const exclusiveReport = report([{ + durability: "persistent", id: "provider-subscription-realm", kind: "volume", + lifecycle: "exclusive-reattach", target: "/var/lib/spawnfile/provider-realm" + }]); + const state: FakeDockerState = { calls: [] }; + const base = createFakeDocker(state, exclusiveReport); + const runDocker = async (args: string[]): Promise => args[0] === "ps" + ? Buffer.from("spawnfile-live\n") + : base(args); + await expect(consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "candidate", + runDocker + })).rejects.toThrow(/stop that deployment before reattaching/u); + expect(state.calls.some((call) => call[0] === "run")).toBe(false); + }); + it("fails preflight before any run when a required secret is missing", async () => { const state: FakeDockerState = { calls: [] }; delete process.env.ANTHROPIC_API_KEY; @@ -289,13 +459,12 @@ describe("consumeImageUp", () => { const backup = movedAside![2]!; // ...and restored from that backup after the new container failed. expect(renames).toContainEqual(["rename", backup, live]); - expect(calls).toContainEqual(["start", live]); - // The failed new container must be force-removed BEFORE the backup is renamed - // back, or the rename would collide with the leftover on a real daemon. - const failedRemovedAt = calls.findIndex((c) => c[0] === "rm" && c[1] === "-f" && c[2] === live); + expect(calls).toContainEqual(["start", previousContainerId]); const restoredAt = calls.findIndex((c) => c[0] === "rename" && c[1] === backup && c[2] === live); - expect(failedRemovedAt).toBeGreaterThanOrEqual(0); - expect(failedRemovedAt).toBeLessThan(restoredAt); + expect(restoredAt).toBeGreaterThanOrEqual(0); + // A failed run returned no verified candidate id, so rollback never deletes + // the ambiguous deployment name. + expect(calls.some((c) => c[0] === "rm" && c.includes(live))).toBe(false); // The backup (the previous deployment) is never force-removed on failure. expect(calls.some((c) => c[0] === "rm" && c.includes(backup))).toBe(false); }); @@ -310,8 +479,91 @@ describe("consumeImageUp", () => { const live = "spawnfile-swap"; const backup = state.calls.find((c) => c[0] === "rename" && c[1] === live)?.[2]; expect(backup).toBeDefined(); - // On success the previous container is force-removed. - expect(state.calls).toContainEqual(["rm", "-f", backup!]); + const readyAt = state.calls.findIndex((call) => + call[0] === "container" && call.some((arg) => arg.includes("{{json .State}}")) + ); + const removedAt = state.calls.findIndex((call) => + call[0] === "rm" && call[1] === "-f" && call[2] === previousContainerId + ); + expect(readyAt).toBeGreaterThanOrEqual(0); + expect(removedAt).toBeGreaterThan(readyAt); + }); + + it("treats an indeterminate incumbent inspect as fatal without mutating by name", async () => { + const calls: string[][] = []; + const base = createFakeDocker({ calls: [] }, undefined, { liveExists: true }); + const runDocker = async (args: string[]): Promise => { + calls.push(args); + if (args[0] === "container" && args[1] === "inspect" + && args[args.length - 1] === "spawnfile-inspect-unknown") { + throw new Error("daemon transport unavailable"); + } + return base(args); + }; + await expect(consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "inspect-unknown", + runDocker + })).rejects.toThrow(/Unable to determine container identity state/u); + expect(calls.some((call) => ["rename", "stop"].includes(call[0]!))).toBe(false); + expect(calls.some((call) => call[0] === "rm" && call.includes("spawnfile-inspect-unknown"))) + .toBe(false); + expect(calls.some((call) => call[0] === "run" && call.includes("-d"))).toBe(false); + }); + + it("restores and re-verifies the incumbent when stop fails after taking effect", async () => { + const calls: string[][] = []; + const base = createFakeDocker({ calls: [] }, undefined, { liveExists: true }); + const runDocker = async (args: string[]): Promise => { + calls.push(args); + if (args[0] === "stop") { + await base(args); + throw new Error("stop acknowledgement lost"); + } + return base(args); + }; + await expect(consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "stop-rollback", + runDocker + })).rejects.toThrow(/stop acknowledgement lost/u); + const live = "spawnfile-stop-rollback"; + const backup = calls.find((call) => call[0] === "rename" && call[1] === live)?.[2]; + expect(backup).toBeDefined(); + expect(calls).toContainEqual(["rename", backup!, live]); + expect(calls).toContainEqual(["start", previousContainerId]); + expect(calls.some((call) => call[0] === "run")).toBe(false); + }); + + it("restores the previous container when candidate readiness inspection fails", async () => { + const calls: string[][] = []; + const base = createFakeDocker({ calls: [] }, undefined, { liveExists: true }); + const runDocker = async (args: string[]): Promise => { + calls.push(args); + if (args[0] === "container" && args.some((arg) => arg.includes("{{json .State}}"))) { + throw new Error("readiness transport failed"); + } + return base(args); + }; + await expect(consumeImageUp("you/org:1.0.0", { + authValues: { ANTHROPIC_API_KEY: "sk", DIST_REQUIRED_TOKEN: "x" }, + deploymentName: "readiness-rollback", + runDocker + })).rejects.toThrow(/readiness transport failed/u); + + const live = "spawnfile-readiness-rollback"; + const backup = calls.find((call) => call[0] === "rename" && call[1] === live)?.[2]; + expect(backup).toBeDefined(); + const candidateRemovedAt = calls.findIndex((call) => + call[0] === "rm" && call[1] === "-f" && call[2] === candidateContainerId + ); + const restoredAt = calls.findIndex((call) => + call[0] === "rename" && call[1] === backup && call[2] === live + ); + expect(candidateRemovedAt).toBeGreaterThanOrEqual(0); + expect(restoredAt).toBeGreaterThan(candidateRemovedAt); + expect(calls).toContainEqual(["start", previousContainerId]); + expect(calls.some((call) => call[0] === "rm" && call.includes(backup!))).toBe(false); }); it("reports the previous ref/digest when explicitly redeploying", async () => { diff --git a/src/distribution/consumeImage.ts b/src/distribution/consumeImage.ts index 6233a133..b01ab14d 100644 --- a/src/distribution/consumeImage.ts +++ b/src/distribution/consumeImage.ts @@ -18,10 +18,18 @@ import { SpawnfileError } from "../shared/index.js"; import { deriveDeploymentName, - deriveVolumeName, + derivePersistentMountVolumeName, renderEnvFileContent, resolveImageEnvironment } from "./consumeImageSupport.js"; +import { + acquireExclusiveVolumeReservations, + assertCandidateContainerReady, + assertContainerStopped, + inspectContainerSnapshot, + restorePreviousContainer, + rollbackCandidateContainer +} from "./consumeImageLifecycle.js"; import { createConsumerDockerRunner } from "./dockerRunner.js"; import type { DockerCommandRunner } from "./dockerRunner.js"; import { extractImageReport, resolveDockerBaseArgs } from "./extractImage.js"; @@ -87,18 +95,6 @@ const resolveRegistryDigest = async ( } }; -const containerExists = async ( - runDocker: DockerCommandRunner, - containerName: string -): Promise => { - try { - await runDocker(["container", "inspect", containerName]); - return true; - } catch { - return false; - } -}; - const resolveLocalImageId = async ( imageRef: string, runDocker: DockerCommandRunner @@ -217,6 +213,7 @@ const consumeImageUpLocked = async ( // port for two containers at once. There is a brief restart gap, unavoidable // when reusing host ports without a proxy. const backupName = `${containerName}-previous-${Date.now().toString(36)}`; + let volumeReservation: Awaited> | undefined; try { await writeFile(envFilePath, renderEnvFileContent(env), "utf8"); @@ -225,21 +222,34 @@ const consumeImageUpLocked = async ( // config is already baked into the image; this only resolves the credential // mounts. Doing it first guarantees an unusable import fails while the live // container is still untouched, never mid-swap. - const authMountArgs: string[] = - options.authProfile && availableImports.length > 0 - ? ( - await prepareImageRuntimeAuthMounts({ - authProfile: options.authProfile, - report, - tempRoot: workDir - }) - ).mountArgs - : []; + const authMountArgs = ( + await prepareImageRuntimeAuthMounts({ + authProfile: options.authProfile ?? null, + report, + tempRoot: workDir + }) + ).mountArgs; - const liveExists = await containerExists(runDocker, containerName); - if (liveExists) { + volumeReservation = await acquireExclusiveVolumeReservations( + report, deploymentName, containerName, imageRef, runDocker + ); + const previousContainer = await inspectContainerSnapshot(runDocker, containerName); + if (previousContainer !== null) { await runDocker(["rename", containerName, backupName]); - await runDocker(["stop", backupName]).catch(() => undefined); + try { + await runDocker(["stop", backupName]); + await assertContainerStopped(runDocker, backupName, previousContainer.id); + } catch (error) { + try { + await restorePreviousContainer(runDocker, previousContainer, backupName, containerName); + } catch { + throw new SpawnfileError( + "runtime_error", + "Prior deployment stop failed and its original state could not be restored" + ); + } + throw error; + } } const runArgs = ["run", "-d", "--name", containerName, "--env-file", envFilePath]; @@ -247,7 +257,7 @@ const consumeImageUpLocked = async ( runArgs.push("-p", `${port}:${port}`); } for (const mount of report.persistent_mounts) { - runArgs.push("-v", `${deriveVolumeName(deploymentName, mount.id)}:${mount.target}`); + runArgs.push("-v", `${derivePersistentMountVolumeName(deploymentName, mount)}:${mount.target}`); } runArgs.push(...authMountArgs); const labels = createDockerDeploymentLabels({ @@ -262,64 +272,68 @@ const consumeImageUpLocked = async ( } runArgs.push(imageRef); - let runOutput: string; + let candidateId: string | undefined; try { - runOutput = (await runDocker(runArgs)).toString("utf8").trim(); + const runOutput = (await runDocker(runArgs)).toString("utf8").trim(); + const observedId = runOutput.split("\n").pop()?.trim(); + if (!observedId || !/^[a-f0-9]{64}$/u.test(observedId)) { + throw new SpawnfileError("runtime_error", "Candidate container returned invalid identity"); + } + candidateId = observedId; + await assertCandidateContainerReady(runDocker, candidateId, containerName); + const imageId = await resolveLocalImageId(imageRef, runDocker); + const digest = await resolveRegistryDigest(imageRef, runDocker); + + const record: DeploymentRecord = { + auth_profile: options.authProfileName ?? null, + compile_fingerprint: inspection.compileFingerprint, + created_at: new Date().toISOString(), + ...(options.envFilePath ? { env_file: path.resolve(options.envFilePath) } : {}), + manager: "docker", + name: deploymentName, + output_directory: null, + source: { digest, kind: "image", ref: imageRef }, + target, + units: [ + { + container_id: candidateId, + container_name: containerName, + contains: buildContainsEntries(report), + id: unitIdFor(deploymentName), + image_id: imageId, + image_tag: imageRef, + kind: "container", + runtime_instances: report.runtime_instances.map((instance) => instance.id).sort() + } + ], + version: "spawnfile.deployment.v2" + }; + + const written = await writeHomeDeployment(record, report); + if (previousContainer !== null) { + await runDocker(["rm", "-f", previousContainer.id]).catch(() => undefined); + } + return { + containerName, + deploymentName, + imageRef, + previous, + record, + recordPath: written.recordPath + }; } catch (error) { - // The new container failed to start; remove the failed attempt and restore - // the previous deployment so a failed redeploy never loses the live one. - await runDocker(["rm", "-f", containerName]).catch(() => undefined); - if (liveExists) { - await runDocker(["rename", backupName, containerName]).catch(() => undefined); - await runDocker(["start", containerName]).catch(() => undefined); + try { + await rollbackCandidateContainer( + runDocker, candidateId, containerName, previousContainer, backupName + ); + } catch (rollbackError) { + throw rollbackError; } throw error; } - - // The new container is up — discard the previous one. - if (liveExists) { - await runDocker(["rm", "-f", backupName]).catch(() => undefined); - } - const containerId = runOutput.split("\n").pop()?.trim() || null; - const imageId = await resolveLocalImageId(imageRef, runDocker); - const digest = await resolveRegistryDigest(imageRef, runDocker); - - const record: DeploymentRecord = { - auth_profile: options.authProfileName ?? null, - compile_fingerprint: inspection.compileFingerprint, - created_at: new Date().toISOString(), - ...(options.envFilePath ? { env_file: path.resolve(options.envFilePath) } : {}), - manager: "docker", - name: deploymentName, - output_directory: null, - source: { digest, kind: "image", ref: imageRef }, - target, - units: [ - { - container_id: containerId, - container_name: containerName, - contains: buildContainsEntries(report), - id: unitIdFor(deploymentName), - image_id: imageId, - image_tag: imageRef, - kind: "container", - runtime_instances: report.runtime_instances.map((instance) => instance.id).sort() - } - ], - version: "spawnfile.deployment.v2" - }; - - const written = await writeHomeDeployment(record, report); - return { - containerName, - deploymentName, - imageRef, - previous, - record, - recordPath: written.recordPath - }; } finally { - await rm(workDir, { force: true, recursive: true }).catch(() => undefined); + try { await volumeReservation?.release(); } + finally { await rm(workDir, { force: true, recursive: true }).catch(() => undefined); } } }; diff --git a/src/distribution/consumeImageLifecycle.test.ts b/src/distribution/consumeImageLifecycle.test.ts new file mode 100644 index 00000000..9f4129d8 --- /dev/null +++ b/src/distribution/consumeImageLifecycle.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + acquireExclusiveVolumeReservations, + assertCandidateContainerReady, + assertContainerStopped, + assertExclusiveVolumesAvailable, + inspectContainerSnapshot, + restorePreviousContainer, + rollbackCandidateContainer +} from "./consumeImageLifecycle.js"; +import type { DockerCommandRunner } from "./dockerRunner.js"; +import type { DistributionReport } from "./types.js"; + +afterEach(() => vi.useRealTimers()); + +const candidateId = "c".repeat(64); +const previousId = "d".repeat(64); +const snapshot = (id: string, name: string, running: boolean): Buffer => Buffer.from([ + JSON.stringify(id), JSON.stringify(`/${name}`), JSON.stringify(running) +].join("\n")); +const ready = (id: string, name: string, state: object): Buffer => Buffer.from([ + JSON.stringify(id), JSON.stringify(`/${name}`), JSON.stringify(state) +].join("\n")); +const exclusiveReport = { + persistent_mounts: [{ + durability: "persistent", id: "realm", kind: "volume", + lifecycle: "exclusive-reattach", target: "/realm" + }] +} as DistributionReport; + +describe("image deployment lifecycle", () => { + it("distinguishes authoritative absence from indeterminate inspect failure", async () => { + await expect(inspectContainerSnapshot( + async () => snapshot(candidateId, "candidate", true), "candidate" + )).resolves.toEqual({ id: candidateId, name: "candidate", running: true }); + await expect(inspectContainerSnapshot( + async () => { throw new Error("No such container: candidate"); }, "candidate" + )).resolves.toBeNull(); + await expect(inspectContainerSnapshot( + async () => { throw new Error("daemon unavailable"); }, "candidate" + )).rejects.toThrow(/Unable to determine container identity state/u); + }); + + it("accepts running healthy state and rejects invalid, drifted, or terminal state", async () => { + await expect(assertCandidateContainerReady(async () => ready(candidateId, "candidate", { + Health: { Status: "healthy" }, Running: true, Status: "running" + }), candidateId, "candidate")).resolves.toBeUndefined(); + await expect(assertCandidateContainerReady(async () => Buffer.from("not-json"), candidateId, "candidate")) + .rejects.toThrow(/invalid readiness state/u); + await expect(assertCandidateContainerReady(async () => ready(candidateId, "peer", { + Running: true, Status: "running" + }), candidateId, "candidate")).rejects.toThrow(/identity did not match/u); + await expect(assertCandidateContainerReady(async () => ready(candidateId, "candidate", { + Running: false, Status: "exited" + }), candidateId, "candidate")).rejects.toThrow(/did not become ready/u); + }); + + it("waits while a running candidate health check is starting", async () => { + vi.useFakeTimers(); + let attempts = 0; + const readiness = assertCandidateContainerReady(async () => ready(candidateId, "candidate", + attempts++ === 0 + ? { Health: { Status: "starting" }, Running: true, Status: "running" } + : { Health: { Status: "healthy" }, Running: true, Status: "running" } + ), candidateId, "candidate"); + await vi.runAllTimersAsync(); + await expect(readiness).resolves.toBeUndefined(); + }); + + it("allows the selected container to occupy its realm and blocks a peer", async () => { + await expect(assertExclusiveVolumesAvailable( + exclusiveReport, "lineage", "selected", async () => Buffer.from("selected\n") + )).resolves.toBeUndefined(); + await expect(assertExclusiveVolumesAvailable( + exclusiveReport, "lineage", "selected", async () => Buffer.from("peer\n") + )).rejects.toThrow(/another running deployment/u); + }); + + it("serializes concurrent volume admission and releases only exact reservation identity", async () => { + const reservations = new Map }>(); + let sequence = 0; + const calls: string[][] = []; + const runDocker: DockerCommandRunner = async (args) => { + calls.push(args); + if (args[0] === "container" && args[1] === "create") { + const name = args[args.indexOf("--name") + 1]!; + if (reservations.has(name)) throw new Error("name conflict"); + const labels: Record = {}; + for (let index = 0; index < args.length; index += 1) { + if (args[index] !== "--label") continue; + const [key, value] = args[index + 1]!.split("=", 2); + labels[key!] = value!; + } + const id = `${++sequence}`.padStart(64, "e"); + reservations.set(name, { id, labels }); + return Buffer.from(id); + } + if (args[0] === "container" && args[1] === "inspect") { + const record = [...reservations.values()].find((value) => value.id === args[args.length - 1]); + if (!record) throw new Error("No such container"); + return Buffer.from(`${JSON.stringify(record.id)}\n${JSON.stringify(record.labels)}`); + } + if (args[0] === "container" && args[1] === "rm") { + const entry = [...reservations.entries()].find(([, value]) => value.id === args[2]); + if (!entry) throw new Error("No such container"); + reservations.delete(entry[0]); + return Buffer.from(""); + } + if (args[0] === "ps") return Buffer.from(""); + throw new Error("unexpected Docker call"); + }; + const contenders = await Promise.allSettled([ + acquireExclusiveVolumeReservations(exclusiveReport, "lineage", "selected", "image:1", runDocker), + acquireExclusiveVolumeReservations(exclusiveReport, "lineage", "selected", "image:1", runDocker) + ]); + const winner = contenders.find((result): result is PromiseFulfilledResult>> => result.status === "fulfilled"); + expect(winner).toBeDefined(); + expect(contenders.filter((result) => result.status === "rejected")).toHaveLength(1); + await winner!.value.release(); + const retry = await acquireExclusiveVolumeReservations( + exclusiveReport, "lineage", "selected", "image:1", runDocker + ); + await retry.release(); + await retry.release(); + expect(reservations.size).toBe(0); + expect(calls.filter((call) => call[0] === "container" && call[1] === "rm")).toHaveLength(2); + }); + + it("removes a verified failed candidate by id and restores prior name and state", async () => { + const values = new Map([ + [candidateId, { id: candidateId, name: "candidate", running: true }], + [previousId, { id: previousId, name: "backup", running: false }] + ]); + const calls: string[][] = []; + const runDocker: DockerCommandRunner = async (args) => { + calls.push(args); + const reference = args[0] === "rename" ? args[1]! : args[args.length - 1]!; + const value = [...values.values()].find((item) => item.id === reference || item.name === reference); + if (args[0] === "container" && args[1] === "inspect") { + if (!value) throw new Error("No such container"); + return snapshot(value.id, value.name, value.running); + } + if (args[0] === "rm") { values.delete(value!.id); return Buffer.from(""); } + if (args[0] === "rename") { value!.name = args[2]!; return Buffer.from(""); } + if (args[0] === "start") { value!.running = true; return Buffer.from(""); } + throw new Error("unexpected"); + }; + await rollbackCandidateContainer( + runDocker, candidateId, "candidate", + { id: previousId, name: "candidate", running: true }, "backup" + ); + expect(calls).toContainEqual(["rm", "-f", candidateId]); + expect(calls).toContainEqual(["start", previousId]); + expect(values.get(previousId)).toMatchObject({ name: "candidate", running: true }); + }); + + it("never deletes by an ambiguous candidate name", async () => { + const runDocker = vi.fn(async () => Buffer.from("")); + await rollbackCandidateContainer(runDocker, undefined, "candidate", null, "backup"); + expect(runDocker).not.toHaveBeenCalled(); + }); + + it("fails closed across malformed identity, stopped-state, and rollback branches", async () => { + await expect(inspectContainerSnapshot(async () => Buffer.from("bad"), "candidate")) + .rejects.toThrow(/invalid identity state/u); + await expect(inspectContainerSnapshot(async () => { throw "transport"; }, "candidate")) + .rejects.toThrow(/Unable to determine container identity state/u); + await expect(assertCandidateContainerReady(async () => Buffer.from(""), "invalid", "candidate")) + .rejects.toThrow(/invalid identity/u); + await expect(assertContainerStopped( + async () => { throw new Error("No such container"); }, "candidate", candidateId + )).rejects.toThrow(/verified stopped state/u); + await expect(assertContainerStopped( + async () => snapshot(previousId, "candidate", false), "candidate", candidateId + )).rejects.toThrow(/verified stopped state/u); + await expect(assertContainerStopped( + async () => snapshot(candidateId, "candidate", true), "candidate", candidateId + )).rejects.toThrow(/verified stopped state/u); + await expect(rollbackCandidateContainer( + async () => snapshot(candidateId, "peer", true), candidateId, "candidate", null, "backup" + )).rejects.toThrow(/candidate cleanup/u); + await expect(rollbackCandidateContainer( + async () => { throw new Error("No such container"); }, undefined, "candidate", + { id: previousId, name: "candidate", running: true }, "backup" + )).rejects.toThrow(/prior restore/u); + }); + + it("restores a prior stopped container by stopping a running renamed backup", async () => { + const value = { id: previousId, name: "backup", running: true }; + const runDocker: DockerCommandRunner = async (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return snapshot(value.id, value.name, value.running); + } + if (args[0] === "rename") { value.name = args[2]!; return Buffer.from(""); } + if (args[0] === "stop") { value.running = false; return Buffer.from(""); } + throw new Error("unexpected"); + }; + await restorePreviousContainer( + runDocker, { id: previousId, name: "candidate", running: false }, "backup", "candidate" + ); + expect(value).toEqual({ id: previousId, name: "candidate", running: false }); + }); + + it("rejects invalid reservation identity and redacts release failures", async () => { + await expect(acquireExclusiveVolumeReservations( + exclusiveReport, "lineage", "selected", "image:1", + async (args) => args.includes("create") ? Buffer.from("invalid") : Buffer.from("") + )).rejects.toThrow(/returned invalid identity/u); + + const id = "a".repeat(64); + let labels: Record = {}; + const runDocker: DockerCommandRunner = async (args) => { + if (args.includes("create")) { + labels = Object.fromEntries(args.flatMap((arg, index) => + arg === "--label" ? [args[index + 1]!.split("=", 2) as [string, string]] : [])); + return Buffer.from(id); + } + if (args.includes("inspect")) return Buffer.from(`${JSON.stringify(id)}\n${JSON.stringify(labels)}`); + if (args[0] === "ps") return Buffer.from(""); + if (args.includes("rm")) throw new Error("provider-secret-diagnostic"); + throw new Error("unexpected"); + }; + const reservation = await acquireExclusiveVolumeReservations( + exclusiveReport, "lineage", "selected", "image:1", runDocker + ); + await expect(reservation.release()).rejects.toThrow( + "Unable to release exclusive persistent mount reservation" + ); + }); +}); diff --git a/src/distribution/consumeImageLifecycle.ts b/src/distribution/consumeImageLifecycle.ts new file mode 100644 index 00000000..9ce8bcb3 --- /dev/null +++ b/src/distribution/consumeImageLifecycle.ts @@ -0,0 +1,278 @@ +import { createHash, randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +import { SpawnfileError } from "../shared/index.js"; + +import { derivePersistentMountVolumeName } from "./consumeImageSupport.js"; +import type { DockerCommandRunner } from "./dockerRunner.js"; +import type { DistributionReport } from "./types.js"; + +interface ContainerState { + Health?: { Status?: string }; + Running?: boolean; + Status?: string; +} + +export interface ContainerSnapshot { + readonly id: string; + readonly name: string; + readonly running: boolean; +} + +export interface ExclusiveVolumeReservation { + release(): Promise; +} + +interface ReservationRecord { + readonly id: string; + readonly name: string; + readonly owner: string; + readonly volumeDigest: string; +} + +const dockerId = /^[a-f0-9]{64}$/u; +const missingContainer = /(?:No such (?:container|object)|container .* not found)/iu; +const snapshotFormat = "{{json .Id}}\n{{json .Name}}\n{{json .State.Running}}"; +const reservationFormat = "{{json .Id}}\n{{json .Config.Labels}}"; +const reservationVersionLabel = "com.spawnfile.exclusive-volume-reservation"; +const reservationOwnerLabel = "com.spawnfile.exclusive-volume-owner"; +const reservationVolumeLabel = "com.spawnfile.exclusive-volume-digest"; + +const parseSnapshot = (raw: Buffer): ContainerSnapshot => { + try { + const [idRaw, nameRaw, runningRaw, ...extra] = raw.toString("utf8").trim().split("\n"); + if (!idRaw || !nameRaw || !runningRaw || extra.length > 0) throw new Error("shape"); + const id = JSON.parse(idRaw) as unknown; + const name = JSON.parse(nameRaw) as unknown; + const running = JSON.parse(runningRaw) as unknown; + if (typeof id !== "string" || !dockerId.test(id) + || typeof name !== "string" || !name.startsWith("/") || name.length < 2 + || typeof running !== "boolean") throw new Error("values"); + return { id, name: name.slice(1), running }; + } catch { + throw new SpawnfileError("runtime_error", "Container returned invalid identity state"); + } +}; + +export const inspectContainerSnapshot = async ( + runDocker: DockerCommandRunner, + reference: string +): Promise => { + try { + return parseSnapshot(await runDocker([ + "container", "inspect", "--format", snapshotFormat, reference + ])); + } catch (error) { + if (error instanceof SpawnfileError && error.message === "Container returned invalid identity state") throw error; + const message = error instanceof Error ? error.message : String(error); + if (missingContainer.test(message)) return null; + throw new SpawnfileError("runtime_error", "Unable to determine container identity state"); + } +}; + +export const assertExclusiveVolumesAvailable = async ( + report: DistributionReport, + deploymentLineage: string, + deploymentContainerName: string, + runDocker: DockerCommandRunner +): Promise => { + for (const mount of report.persistent_mounts.filter( + (candidate) => candidate.lifecycle === "exclusive-reattach" + )) { + const volume = derivePersistentMountVolumeName(deploymentLineage, mount); + const occupants = (await runDocker([ + "ps", "--filter", `volume=${volume}`, "--format", "{{.Names}}" + ])).toString("utf8").split("\n").map((name) => name.trim()).filter(Boolean); + if (occupants.some((name) => name !== deploymentContainerName)) { + throw new SpawnfileError( + "runtime_error", + `Exclusive persistent mount ${mount.id} is attached to another running deployment; stop that deployment before reattaching the realm` + ); + } + } +}; + +const reservationName = (volumeDigest: string): string => + `spawnfile-volume-reservation-${volumeDigest.slice("sha256:".length, 24 + "sha256:".length)}`; + +const inspectReservation = async ( + runDocker: DockerCommandRunner, + record: ReservationRecord +): Promise => { + try { + const output = (await runDocker([ + "container", "inspect", "--format", reservationFormat, record.id + ])).toString("utf8").trim().split("\n"); + if (output.length !== 2) throw new Error("shape"); + const id = JSON.parse(output[0]!) as unknown; + const labels = JSON.parse(output[1]!) as unknown; + if (id !== record.id || !labels || typeof labels !== "object" || Array.isArray(labels)) throw new Error("identity"); + const values = labels as Record; + if (values[reservationVersionLabel] !== "v1" + || values[reservationOwnerLabel] !== record.owner + || values[reservationVolumeLabel] !== record.volumeDigest) throw new Error("authority"); + } catch { + throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation identity is unavailable"); + } +}; + +const releaseReservations = async ( + runDocker: DockerCommandRunner, + records: readonly ReservationRecord[] +): Promise => { + let failed = false; + for (const record of [...records].reverse()) { + try { + await inspectReservation(runDocker, record); + await runDocker(["container", "rm", record.id]); + } catch { failed = true; } + } + if (failed) throw new SpawnfileError("runtime_error", "Unable to release exclusive persistent mount reservation"); +}; + +export const acquireExclusiveVolumeReservations = async ( + report: DistributionReport, + deploymentLineage: string, + deploymentContainerName: string, + imageReference: string, + runDocker: DockerCommandRunner +): Promise => { + const volumes = report.persistent_mounts + .filter((mount) => mount.lifecycle === "exclusive-reattach") + .map((mount) => derivePersistentMountVolumeName(deploymentLineage, mount)) + .sort(); + const records: ReservationRecord[] = []; + try { + for (const volume of volumes) { + const volumeDigest = `sha256:${createHash("sha256").update(volume).digest("hex")}`; + const owner = randomUUID(); + const name = reservationName(volumeDigest); + let id: string; + try { + id = (await runDocker([ + "container", "create", "--name", name, + "--label", `${reservationVersionLabel}=v1`, + "--label", `${reservationOwnerLabel}=${owner}`, + "--label", `${reservationVolumeLabel}=${volumeDigest}`, + imageReference + ])).toString("utf8").trim(); + } catch { + throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation is already held"); + } + if (!dockerId.test(id)) throw new SpawnfileError("runtime_error", "Exclusive persistent mount reservation returned invalid identity"); + const record = { id, name, owner, volumeDigest }; + records.push(record); + await inspectReservation(runDocker, record); + } + await assertExclusiveVolumesAvailable(report, deploymentLineage, deploymentContainerName, runDocker); + } catch (error) { + if (records.length > 0) await releaseReservations(runDocker, records); + throw error; + } + let released = false; + return { + release: async () => { + if (released) return; + await releaseReservations(runDocker, records); + released = true; + } + }; +}; + +const parseCandidateState = (raw: Buffer): { snapshot: ContainerSnapshot; state: ContainerState } => { + try { + const [idRaw, nameRaw, stateRaw, ...extra] = raw.toString("utf8").trim().split("\n"); + if (!idRaw || !nameRaw || !stateRaw || extra.length > 0) throw new Error("shape"); + const id = JSON.parse(idRaw) as unknown; + const name = JSON.parse(nameRaw) as unknown; + const state = JSON.parse(stateRaw) as ContainerState; + if (typeof id !== "string" || !dockerId.test(id) + || typeof name !== "string" || !name.startsWith("/") || name.length < 2 + || !state || typeof state !== "object" || typeof state.Running !== "boolean") throw new Error("values"); + return { snapshot: { id, name: name.slice(1), running: state.Running }, state }; + } catch { + throw new SpawnfileError("runtime_error", "Candidate container returned invalid readiness state"); + } +}; + +export const assertCandidateContainerReady = async ( + runDocker: DockerCommandRunner, + candidateId: string, + expectedName: string +): Promise => { + if (!dockerId.test(candidateId)) throw new SpawnfileError("runtime_error", "Candidate container returned invalid identity"); + for (let attempt = 0; attempt < 30; attempt += 1) { + const { snapshot, state } = parseCandidateState(await runDocker([ + "container", "inspect", "--format", "{{json .Id}}\n{{json .Name}}\n{{json .State}}", candidateId + ])); + if (snapshot.id !== candidateId || snapshot.name !== expectedName) { + throw new SpawnfileError("runtime_error", "Candidate container identity did not match detached run"); + } + const health = state.Health?.Status; + if (state.Running === true && (health === undefined || health === "healthy")) return; + if (state.Running !== true || (health !== undefined && health !== "starting")) break; + await delay(1_000); + } + throw new SpawnfileError("runtime_error", "Candidate container did not become ready"); +}; + +export const assertContainerStopped = async ( + runDocker: DockerCommandRunner, + reference: string, + expectedId: string +): Promise => { + const observed = await inspectContainerSnapshot(runDocker, reference); + if (observed === null || observed.id !== expectedId || observed.running) { + throw new SpawnfileError("runtime_error", "Prior container did not reach a verified stopped state"); + } +}; + +export const restorePreviousContainer = async ( + runDocker: DockerCommandRunner, + previous: ContainerSnapshot, + backupName: string, + deploymentName: string +): Promise => { + const backup = await inspectContainerSnapshot(runDocker, backupName); + if (backup === null || backup.id !== previous.id) { + throw new SpawnfileError("runtime_error", "Prior container rollback identity is unavailable"); + } + await runDocker(["rename", backupName, deploymentName]); + if (previous.running !== backup.running) { + await runDocker([previous.running ? "start" : "stop", previous.id]); + } + const restored = await inspectContainerSnapshot(runDocker, previous.id); + if (restored === null || restored.id !== previous.id || restored.name !== deploymentName + || restored.running !== previous.running) { + throw new SpawnfileError("runtime_error", "Prior container rollback state could not be verified"); + } +}; + +export const rollbackCandidateContainer = async ( + runDocker: DockerCommandRunner, + candidateId: string | undefined, + candidateName: string, + previous: ContainerSnapshot | null, + backupName: string +): Promise => { + const failed: string[] = []; + if (candidateId !== undefined) { + try { + const candidate = await inspectContainerSnapshot(runDocker, candidateId); + if (candidate !== null) { + if (candidate.id !== candidateId || candidate.name !== candidateName) throw new Error("identity"); + await runDocker(["rm", "-f", candidateId]); + } + } catch { failed.push("candidate cleanup"); } + } + if (previous !== null) { + try { await restorePreviousContainer(runDocker, previous, backupName, candidateName); } + catch { failed.push("prior restore"); } + } + if (failed.length > 0) { + throw new SpawnfileError( + "runtime_error", + `Candidate deployment failed and rollback was incomplete (${failed.join(", ")})` + ); + } +}; diff --git a/src/distribution/consumeImageSupport.test.ts b/src/distribution/consumeImageSupport.test.ts index 1e0a6d42..11395c4b 100644 --- a/src/distribution/consumeImageSupport.test.ts +++ b/src/distribution/consumeImageSupport.test.ts @@ -2,11 +2,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { deriveDeploymentName, + derivePersistentMountVolumeName, deriveVolumeName, renderEnvFileContent, resolveImageEnvironment } from "./consumeImageSupport.js"; import { parseImageReference } from "./imageRef.js"; +import type { ParsedImageReference } from "./imageRef.js"; import type { DistributionReport } from "./types.js"; const ref = (value: string) => { @@ -48,6 +50,10 @@ describe("deriveDeploymentName", () => { expect(deriveDeploymentName(ref("you/research-cell:1.0.0"))).toBe("research-cell"); expect(deriveDeploymentName(ref("ghcr.io/org/my_org:latest"))).toBe("my-org"); }); + + it("uses a generic deployment name when the repository normalizes empty", () => { + expect(deriveDeploymentName({ name: "---" } as ParsedImageReference)).toBe("deployment"); + }); }); describe("deriveVolumeName", () => { @@ -58,6 +64,30 @@ describe("deriveVolumeName", () => { it("derives distinct volume names for two deployments of one image", () => { expect(deriveVolumeName("a", "store")).not.toBe(deriveVolumeName("b", "store")); }); + + it("scopes an exclusive realm to one explicit deployment lineage", () => { + const mount = { + durability: "persistent" as const, + id: "provider-subscription-realm", + kind: "volume" as const, + lifecycle: "exclusive-reattach" as const, + target: "/var/lib/example/realm" + }; + expect(derivePersistentMountVolumeName("blue", mount)).not.toBe( + derivePersistentMountVolumeName("green", mount) + ); + expect(derivePersistentMountVolumeName("blue", mount)).toMatch( + /^spawnfile-exclusive-provider-subscription-realm-[a-f0-9]{16}$/u + ); + }); + + it("keeps legacy mounts without exclusive lifecycle deployment-local", () => { + expect(derivePersistentMountVolumeName("blue", { + id: "cache", + lifecycle: "legacy" + } as unknown as DistributionReport["persistent_mounts"][number])).toBe("spawnfile_blue_cache"); + }); + }); describe("resolveImageEnvironment", () => { diff --git a/src/distribution/consumeImageSupport.ts b/src/distribution/consumeImageSupport.ts index 0cc723ff..83b8303c 100644 --- a/src/distribution/consumeImageSupport.ts +++ b/src/distribution/consumeImageSupport.ts @@ -3,6 +3,8 @@ import { randomBytes } from "node:crypto"; import { normalizeProjectLabelSlug } from "./projectName.js"; import type { ParsedImageReference } from "./imageRef.js"; import type { DistributionReport } from "./types.js"; +import type { DistributionPersistentMount } from "./types.js"; +import { createExclusiveReattachVolumeName } from "../shared/index.js"; const GENERATED_RUNTIME_SECRET_NAMES = new Set([ "OPENCLAW_GATEWAY_TOKEN", @@ -25,6 +27,13 @@ export const deriveVolumeName = (deploymentName: string, mountId: string): strin return `spawnfile_${deploymentName}_${safeMount}`; }; +export const derivePersistentMountVolumeName = ( + deploymentName: string, + mount: DistributionPersistentMount +): string => mount.lifecycle === "exclusive-reattach" + ? createExclusiveReattachVolumeName(deploymentName, mount.id) + : deriveVolumeName(deploymentName, mount.id); + export interface ResolveImageEnvironmentInput { authValues: Record; envFileEnv?: Record; diff --git a/src/distribution/distributionReportSchema.test.ts b/src/distribution/distributionReportSchema.test.ts index 9f13655f..7d1c368a 100644 --- a/src/distribution/distributionReportSchema.test.ts +++ b/src/distribution/distributionReportSchema.test.ts @@ -73,6 +73,17 @@ describe("parseDistributionReport", () => { expect(() => parseDistributionReport(report)).toThrow(/Invalid distribution report/); }); + it("accepts only the declared exclusive reattach lifecycle", () => { + const report = validReport(); + report.persistent_mounts = [{ + durability: "persistent", id: "realm", kind: "volume", + lifecycle: "exclusive-reattach", target: "/var/lib/example/realm" + }]; + expect(parseDistributionReport(report).persistent_mounts[0]?.lifecycle).toBe("exclusive-reattach"); + (report.persistent_mounts[0] as { lifecycle?: string }).lifecycle = "clone"; + expect(() => parseDistributionReport(report)).toThrow(/Invalid distribution report/u); + }); + it("rejects a runtime home_path containing '..'", () => { const report = validReport(); report.runtime_instances = [ @@ -135,4 +146,40 @@ describe("parseDistributionReport", () => { ]; expect(() => parseDistributionReport(report)).not.toThrow(); }); + + it("accepts current engine disclosures and workspace resource kinds", () => { + const report = validReport(); + report.runtime_instances = [{ + config_path: "/var/lib/spawnfile/daimon/config.json", + engine_by_node_id: { "agent:reviewer": "grok", "agent:writer": "codex" }, + home_path: "/var/lib/spawnfile/daimon/home", + id: "daimon-organization", + internal_port: null, + model_auth_methods: {}, + model_secrets_required: [], + node_ids: ["agent:reviewer", "agent:writer"], + published_port: null, + runtime: "daimon", + workspace_path: "/var/lib/spawnfile/daimon/workspace" + }]; + report.resources = [{ + id: "workspace-seed", kind: "bundle", link_path: "/workspace/seed", + mode: "readonly", mount: "./seed", sharing: "per_agent" + }]; + expect(parseDistributionReport(report)).toEqual(report); + }); + + it("rejects unknown engine and resource variants", () => { + const report = validReport(); + report.runtime_instances = [{ + config_path: "/config", engine_by_node_id: { "agent:x": "unknown-engine" as never }, + home_path: "/home", id: "daimon-organization", internal_port: null, + model_auth_methods: {}, model_secrets_required: [], node_ids: ["agent:x"], + published_port: null, runtime: "daimon", workspace_path: "/workspace" + }]; + expect(() => parseDistributionReport(report)).toThrow(/Invalid distribution report/u); + delete report.runtime_instances[0]!.engine_by_node_id; + report.resources = [{ id: "x", kind: "device" as never, link_path: "/x", mode: "readonly", mount: "./x", sharing: "per_agent" }]; + expect(() => parseDistributionReport(report)).toThrow(/Invalid distribution report/u); + }); }); diff --git a/src/distribution/distributionReportSchema.ts b/src/distribution/distributionReportSchema.ts index f34c7603..510ffb05 100644 --- a/src/distribution/distributionReportSchema.ts +++ b/src/distribution/distributionReportSchema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { SpawnfileError } from "../shared/index.js"; import { + DISTRIBUTION_ENGINE_KINDS, DISTRIBUTION_REPORT_VERSION, WORLD_BINDINGS_IMAGE_PATH } from "./types.js"; @@ -47,6 +48,10 @@ const instanceIdSchema = z const runtimeInstanceSchema = z.object({ config_path: containerPathSchema, + engine_by_node_id: z.record( + z.string().min(1), + z.enum(DISTRIBUTION_ENGINE_KINDS) + ).optional(), home_path: containerPathSchema.nullable(), id: instanceIdSchema, internal_port: portSchema.nullable(), @@ -90,6 +95,7 @@ export const distributionReportSchema = z.object({ // schema alone keeps the volume-name source clean (defense in depth). id: instanceIdSchema, kind: z.literal("volume"), + lifecycle: z.literal("exclusive-reattach").optional(), target: containerPathSchema }).strict()), port_mappings: z.array(z.object({ @@ -99,7 +105,7 @@ export const distributionReportSchema = z.object({ ports: z.array(portSchema), resources: z.array(z.object({ id: z.string().min(1), - kind: z.union([z.literal("git"), z.literal("volume")]), + kind: z.union([z.literal("bundle"), z.literal("git"), z.literal("volume")]), link_path: z.string(), mode: z.union([z.literal("mutable"), z.literal("readonly")]), mount: z.string(), diff --git a/src/distribution/extractImage.test.ts b/src/distribution/extractImage.test.ts index 7db71645..0277e128 100644 --- a/src/distribution/extractImage.test.ts +++ b/src/distribution/extractImage.test.ts @@ -81,6 +81,30 @@ describe("extractImageReport", () => { expect(inspection.report.organization.project).toBe("org"); }); + it("round-trips the current producer engine map and broker resources through the embedded image report", async () => { + const current = buildDistributionReport({ + envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], + modelAuthMethods: {}, moltnetNetworks: [], + organization: { agents: [{ id: "agent:writer", name: "writer", runtime: "daimon", teams: [] }], project: "org", teams: [] }, + persistentMounts: [{ durability: "persistent", id: "grok-realm", kind: "volume", lifecycle: "exclusive-reattach", target: "/var/lib/daimon-engine-broker/realm" }], + portMappings: [], publishedPorts: [], + resources: [{ id: "workspace-seed", kind: "bundle", link_path: "/workspace/seed", mode: "readonly", mount: "./seed", sharing: "per_agent" }], + runtimeInstances: [{ config_path: "/etc/daimon/config.json", engine_by_node_id: { "agent:writer": "grok" }, home_path: "/var/lib/daimon", id: "daimon-organization", internal_port: null, model_auth_methods: {}, model_secrets_required: [], node_ids: ["agent:writer"], published_port: null, runtime: "daimon", workspace_path: "/workspace" }] + }); + const currentLabels = { + "com.spawnfile.compile_fingerprint": current.compile_fingerprint, + "com.spawnfile.image_contract": "spawnfile.image.v1", + "com.spawnfile.project": "org", + "com.spawnfile.report": DISTRIBUTION_REPORT_IMAGE_PATH + }; + const inspection = await extractImageReport("you/org:v3", { + runDocker: runnerFor(currentLabels, JSON.stringify(current)) + }); + expect(inspection.report.runtime_instances[0]?.engine_by_node_id).toEqual({ "agent:writer": "grok" }); + expect(inspection.report.resources[0]?.kind).toBe("bundle"); + expect(inspection.report.persistent_mounts[0]?.lifecycle).toBe("exclusive-reattach"); + }); + it("pulls the image when requested", async () => { const calls: string[][] = []; await extractImageReport("you/org:1.0.0", { diff --git a/src/distribution/imageRuntimeAuth.test.ts b/src/distribution/imageRuntimeAuth.test.ts index bbe08dd1..a3ad17f2 100644 --- a/src/distribution/imageRuntimeAuth.test.ts +++ b/src/distribution/imageRuntimeAuth.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; @@ -120,7 +120,81 @@ const codexReport = () => ] }); +const daimonReport = () => buildDistributionReport({ + envVariables: [], generatedAt: "2026-08-26T00:00:00.000Z", internalPorts: [], + modelAuthMethods: {}, moltnetNetworks: [], + organization: { agents: [ + { id: "agent:coder", name: "coder", runtime: "daimon", teams: [] }, + { id: "agent:reviewer", name: "reviewer", runtime: "daimon", teams: [] } + ], project: "org", teams: [] }, + persistentMounts: [], portMappings: [], publishedPorts: [], resources: [], + runtimeInstances: [{ + config_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json", + engine_by_node_id: { "agent:coder": "codex", "agent:reviewer": "grok" }, + home_path: null, id: "daimon-organization", internal_port: null, + model_auth_methods: {}, model_secrets_required: [], + node_ids: ["agent:coder", "agent:reviewer"], published_port: null, + runtime: "daimon", workspace_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace" + }] +}); + +const directDaimonSources = async () => { + const root = await tempDir(), codex = path.join(root, "codex.json"), grok = path.join(root, "grok.json"); + await writeFile(codex, JSON.stringify({ tokens: { access_token: "fake-access", refresh_token: "fake-refresh" } }), { mode: 0o600 }); + await writeFile(grok, JSON.stringify({ "https://auth.x.ai::fixture": { key: "a".repeat(32), refresh_token: "r".repeat(16), expires_at: "2099-01-01T00:00:00.000Z" } }), { mode: 0o600 }); + return { codex, grok, environment: { SPAWNFILE_DAIMON_SOURCE_CODEX_AUTH: codex, SPAWNFILE_DAIMON_SOURCE_GROK_AUTH: grok } }; +}; + describe("prepareImageRuntimeAuthMounts", () => { + it("mounts direct Daimon provider sources from an embedded engine map without an auth profile", async () => { + const sources = await directDaimonSources(); + const result = await prepareImageRuntimeAuthMounts({ + authProfile: null, report: daimonReport(), sourceEnvironment: sources.environment, + tempRoot: await tempDir() + }); + expect(result.mountArgs).toContain(`${sources.codex}:/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/coder/.daimon-inbound/codex-auth:ro`); + expect(result.mountArgs).toContain(`${sources.grok}:/var/lib/spawnfile/daimon/grok-bootstrap-auth:ro`); + }); + + it("fails closed on missing, permissive, or linked direct Daimon sources", async () => { + const sources = await directDaimonSources(); + await expect(prepareImageRuntimeAuthMounts({ + authProfile: null, report: daimonReport(), + sourceEnvironment: { ...sources.environment, SPAWNFILE_DAIMON_SOURCE_GROK_AUTH: path.join(await tempDir(), "missing") }, + tempRoot: await tempDir() + })).rejects.toThrow(/missing the selected grok artifact/u); + await chmod(sources.grok, 0o644); + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: daimonReport(), sourceEnvironment: sources.environment, tempRoot: await tempDir() })).rejects.toThrow(/caller-owned 0600 regular file/u); + await chmod(sources.grok, 0o600); + const linked = path.join(await tempDir(), "linked.json"); await symlink(sources.grok, linked); + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: daimonReport(), sourceEnvironment: { ...sources.environment, SPAWNFILE_DAIMON_SOURCE_GROK_AUTH: linked }, tempRoot: await tempDir() })).rejects.toThrow(/caller-owned 0600 regular file/u); + }); + + it("does not prepare unrelated runtimes when no auth profile is selected", async () => { + const result = await prepareImageRuntimeAuthMounts({ authProfile: null, report: report(), tempRoot: await tempDir() }); + expect(result.mountArgs).toEqual([]); + }); + + it("rejects a Daimon engine map that is incomplete or invalid for Daimon", async () => { + const sources = await directDaimonSources(), incomplete = daimonReport(); + incomplete.runtime_instances[0]!.engine_by_node_id = { "agent:coder": "codex" }; + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: incomplete, sourceEnvironment: sources.environment, tempRoot: await tempDir() })).rejects.toThrow(/does not match its declared agents/u); + const invalid = daimonReport(); invalid.runtime_instances[0]!.engine_by_node_id!["agent:reviewer"] = "scripted"; + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: invalid, sourceEnvironment: sources.environment, tempRoot: await tempDir() })).rejects.toThrow(/unsupported engine/u); + }); + + it("rejects redirected Daimon paths and cross-engine slug collisions", async () => { + const sources = await directDaimonSources(); + for (const field of ["config_path", "workspace_path"] as const) { + const redirected = daimonReport(); redirected.runtime_instances[0]![field] = `/tmp/${field}`; + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: redirected, sourceEnvironment: sources.environment, tempRoot: await tempDir() })).rejects.toThrow(/paths are not canonical/u); + } + const collision = daimonReport(); + collision.runtime_instances[0]!.node_ids = ["agent:review er", "agent:review-er"]; + collision.runtime_instances[0]!.engine_by_node_id = { "agent:review er": "codex", "agent:review-er": "grok" }; + await expect(prepareImageRuntimeAuthMounts({ authProfile: null, report: collision, sourceEnvironment: sources.environment, tempRoot: await tempDir() })).rejects.toThrow(/unsafe agent id/u); + }); + it("mounts the credential profile and the import directory into the runtime home", async () => { const importDir = await claudeImportDir(); const profile: ResolvedAuthProfile = { diff --git a/src/distribution/imageRuntimeAuth.ts b/src/distribution/imageRuntimeAuth.ts index 7aab196f..78986ed1 100644 --- a/src/distribution/imageRuntimeAuth.ts +++ b/src/distribution/imageRuntimeAuth.ts @@ -7,13 +7,24 @@ import { } from "../auth/index.js"; import { fileExists } from "../filesystem/index.js"; import { getRuntimeAdapter } from "../runtime/index.js"; +import { + DAIMON_AGY_SUBSCRIPTION_REALM, + DAIMON_ENGINE_CREDENTIALS, + DAIMON_GROK_SUBSCRIPTION_REALM +} from "../runtime/daimon/contractManifest.js"; +import { + assertSafeDaimonSourceFile, + DAIMON_AGY_UNLOCK_SOURCE_ENV, + daimonSourcePathForEngine +} from "../runtime/daimon/runAuth.js"; import { SpawnfileError } from "../shared/index.js"; import type { DistributionReport } from "./types.js"; export interface ImageRuntimeAuthInput { - authProfile: ResolvedAuthProfile; + authProfile: ResolvedAuthProfile | null; report: DistributionReport; + sourceEnvironment?: Record; tempRoot: string; } @@ -25,6 +36,67 @@ export interface ImageRuntimeAuthResult { const importMountTargetName = (kind: "claude-code" | "codex"): string => kind === "claude-code" ? ".claude" : ".codex"; +const daimonNodeSlug = (nodeId: string): string => + nodeId.replace(/^agent:/u, "").toLowerCase() + .replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, ""); + +const daimonInstanceRoot = ( + instance: DistributionReport["runtime_instances"][number] +): string => { + const root = path.posix.join("/var/lib/spawnfile/instances/daimon", instance.id); + if (instance.config_path !== path.posix.join(root, "daimon/daimon-organization-runtime.json") + || instance.workspace_path !== path.posix.join(root, "workspace")) { + throw new SpawnfileError("validation_error", "Daimon image runtime paths are not canonical"); + } + return root; +}; + +const prepareDaimonImageAuthMounts = async ( + instance: DistributionReport["runtime_instances"][number], + environment: Record +): Promise => { + const engines = Object.entries(instance.engine_by_node_id ?? {}).sort(([left], [right]) => left.localeCompare(right)); + if (engines.length === 0) { + throw new SpawnfileError("validation_error", "Daimon image report is missing its engine map"); + } + const engineNodeIds = engines.map(([nodeId]) => nodeId); + if (JSON.stringify(engineNodeIds) !== JSON.stringify([...instance.node_ids].sort())) { + throw new SpawnfileError("validation_error", "Daimon image engine map does not match its declared agents"); + } + if (engines.some(([, engine]) => engine !== "agy" && engine !== "codex" && engine !== "grok")) { + throw new SpawnfileError("validation_error", "Daimon image report declares an unsupported engine"); + } + const root = daimonInstanceRoot(instance); + const mounts: string[] = []; + const slugs = new Map(); + for (const [nodeId] of engines) { + const slug = daimonNodeSlug(nodeId); + if (!slug || slugs.has(slug)) throw new SpawnfileError("validation_error", "Daimon image report has an unsafe agent id"); + slugs.set(slug, nodeId); + } + const codexSource = engines.some(([, engine]) => engine === "codex") + ? daimonSourcePathForEngine("codex", environment) : null; + if (codexSource) await assertSafeDaimonSourceFile(codexSource, "codex", 64 * 1024, "codex"); + for (const [nodeId, engine] of engines) { + if (engine !== "codex") continue; + const slug = daimonNodeSlug(nodeId); + const target = path.posix.join(root, "runtime-homes", slug, DAIMON_ENGINE_CREDENTIALS.codex.sourceRelativePath); + mounts.push("-v", `${codexSource}:${target}:ro`); + } + if (engines.some(([, engine]) => engine === "grok")) { + const source = daimonSourcePathForEngine("grok", environment); + await assertSafeDaimonSourceFile(source, "grok", DAIMON_GROK_SUBSCRIPTION_REALM.maxCredentialBytes, "grok"); + mounts.push("-v", `${source}:${DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapMountPath}:ro`); + } + if (engines.some(([, engine]) => engine === "agy")) { + const source = environment[DAIMON_AGY_UNLOCK_SOURCE_ENV]?.trim(); + if (!source) throw new SpawnfileError("validation_error", "Daimon runtime auth is missing the operator-authorized AGY realm unlock artifact"); + await assertSafeDaimonSourceFile(source, "AGY realm unlock", DAIMON_AGY_SUBSCRIPTION_REALM.maxUnlockBytes); + mounts.push("-v", `${source}:${DAIMON_AGY_SUBSCRIPTION_REALM.unlockMountPath}:ro`); + } + return mounts; +}; + /** * Builds the credential mounts for a sourceless image deployment that uses * import-based model auth. The OAuth-mode config is already baked into the @@ -43,6 +115,11 @@ export const prepareImageRuntimeAuthMounts = async ( if (instance.home_path) { runtimeHomes.add(instance.home_path); } + if (instance.runtime === "daimon") { + mountArgs.push(...await prepareDaimonImageAuthMounts(instance, input.sourceEnvironment ?? process.env)); + continue; + } + if (!input.authProfile) continue; const adapter = getRuntimeAdapter(instance.runtime); if (!adapter.prepareRuntimeAuth) { continue; @@ -70,7 +147,7 @@ export const prepareImageRuntimeAuthMounts = async ( // Mount the raw credential import directories into each runtime home so the // runtime's OAuth client can read them (e.g. ~/.claude, ~/.codex). for (const kind of ["claude-code", "codex"] as const) { - const entry = input.authProfile.imports[kind]; + const entry = input.authProfile?.imports[kind]; if (!entry) { continue; } diff --git a/src/distribution/index.ts b/src/distribution/index.ts index 2e7a08f9..de643411 100644 --- a/src/distribution/index.ts +++ b/src/distribution/index.ts @@ -14,6 +14,7 @@ export { consumeImageUp } from "./consumeImage.js"; export type { ConsumeImageUpOptions, ConsumeImageUpResult } from "./consumeImage.js"; export { deriveDeploymentName, + derivePersistentMountVolumeName, deriveVolumeName, renderEnvFileContent, resolveImageEnvironment diff --git a/src/distribution/types.ts b/src/distribution/types.ts index 653bc669..b28ad557 100644 --- a/src/distribution/types.ts +++ b/src/distribution/types.ts @@ -32,12 +32,13 @@ export interface DistributionPersistentMount { durability: "persistent"; id: string; kind: "volume"; + lifecycle?: "exclusive-reattach"; target: string; } export interface DistributionWorkspaceResource { id: string; - kind: "git" | "volume"; + kind: "bundle" | "git" | "volume"; link_path: string; mode: "mutable" | "readonly"; mount: string; @@ -46,6 +47,7 @@ export interface DistributionWorkspaceResource { export interface DistributionRuntimeInstance { config_path: string; + engine_by_node_id?: Record; home_path: string | null; id: string; internal_port: number | null; @@ -57,6 +59,11 @@ export interface DistributionRuntimeInstance { workspace_path: string; } +export const DISTRIBUTION_ENGINE_KINDS = [ + "agy", "claude", "codex", "grok", "pi", "scripted" +] as const; +export type DistributionEngineKind = typeof DISTRIBUTION_ENGINE_KINDS[number]; + export interface DistributionMoltnetNetwork { binding: "env"; id: string; From 490f08a45502240282cb7e34ec74dab82a87eb74 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:44 +0200 Subject: [PATCH 13/34] test(e2e): compile explicit runtime mcp declarations --- scripts/compile-explicit-test-mcp.mjs | 22 ++++++++++++++++++++++ scripts/compile-explicit-test-mcp.test.mjs | 5 +++++ 2 files changed, 27 insertions(+) create mode 100644 scripts/compile-explicit-test-mcp.mjs create mode 100644 scripts/compile-explicit-test-mcp.test.mjs diff --git a/scripts/compile-explicit-test-mcp.mjs b/scripts/compile-explicit-test-mcp.mjs new file mode 100644 index 00000000..4f585c12 --- /dev/null +++ b/scripts/compile-explicit-test-mcp.mjs @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const args = Object.fromEntries(process.argv.slice(2).reduce((rows, item, index, all) => item.startsWith("--") ? [...rows, [item.slice(2), all[index + 1]]] : rows, [])); +if (!args.declaration || !args.report || !args.out) throw new Error("usage: --declaration --report --out "); +const declarationBytes = await readFile(path.resolve(args.declaration)); const declaration = JSON.parse(declarationBytes); +const report = JSON.parse(await readFile(path.resolve(args.report), "utf8")); +if (!report || !/^sf1:[a-f0-9]{12}$/u.test(report.compile_fingerprint)) throw new Error("explicit-test MCP lowering requires a compiled Spawnfile report"); +const compiledAgents = new Set((report.container?.runtime_instances ?? []).filter((instance) => instance.runtime === "daimon").flatMap((instance) => instance.node_ids ?? [])); +exact(declaration, ["version", "servers"]); if (declaration.version !== "spawnfile.explicit-test-mcp-declaration.v1" || !Array.isArray(declaration.servers) || declaration.servers.length > 8) throw new Error("invalid explicit-test MCP declaration"); +const servers = declaration.servers.map((value) => { exact(value, ["id", "agent_id", "command", "args", "tools", "env_names"]); if (![value.id, value.agent_id].every(identifier) || !absolute(value.command) || !strings(value.args, 16, absolute) || !strings(value.tools, 16, identifier) || !strings(value.env_names, 16, identifier)) throw new Error("invalid explicit-test MCP server declaration"); return value; }).sort((left, right) => left.id.localeCompare(right.id)); +if (servers.some(({ agent_id }) => !compiledAgents.has(agent_id))) throw new Error("explicit-test MCP server agent is absent from compiled Daimon instances"); +if (new Set(servers.map(({ id }) => id)).size !== servers.length) throw new Error("duplicate explicit-test MCP server id"); +const artifact = { version: "spawnfile.explicit-test-mcp.v1", compile_fingerprint: report.compile_fingerprint, servers }; +const artifactBytes = Buffer.from(`${JSON.stringify(artifact)}\n`); const digest = value => `sha256:${createHash("sha256").update(value).digest("hex")}`; +const receipt = { version: "spawnfile.explicit-test-mcp-receipt.v1", compile_fingerprint: report.compile_fingerprint, declaration_sha256: digest(declarationBytes), artifact_sha256: digest(artifactBytes), servers: servers.map(({ id, agent_id, tools }) => ({ id, agent_id, tools })) }; +await mkdir(path.resolve(args.out), { recursive: true }); await writeFile(path.resolve(args.out, "explicit-test-mcp.json"), artifactBytes, { mode: 0o600 }); await writeFile(path.resolve(args.out, "explicit-test-mcp-receipt.json"), `${JSON.stringify(receipt)}\n`, { mode: 0o600 }); +function exact(value, keys) { if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) throw new Error("unexpected explicit-test MCP field"); } +function identifier(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/u.test(value); } +function absolute(value) { return typeof value === "string" && value.startsWith("/") && value.length <= 1024; } +function strings(value, limit, validator) { return Array.isArray(value) && value.length <= limit && value.every(validator) && new Set(value).size === value.length; } diff --git a/scripts/compile-explicit-test-mcp.test.mjs b/scripts/compile-explicit-test-mcp.test.mjs new file mode 100644 index 00000000..2b122dc6 --- /dev/null +++ b/scripts/compile-explicit-test-mcp.test.mjs @@ -0,0 +1,5 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import test from "node:test"; +test("explicit-test MCP lowering binds compiled identity and declared server tools", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-test-mcp-")); try { const declaration = path.join(root, "declaration.json"), report = path.join(root, "report.json"), out = path.join(root, "out"); await writeFile(declaration, JSON.stringify({ version: "spawnfile.explicit-test-mcp-declaration.v1", servers: [{ id: "fixture", agent_id: "agent:alpha", command: "/usr/local/bin/node", args: ["/fixture/server.mjs"], tools: ["checkpoint"], env_names: [] }] })); await writeFile(report, JSON.stringify({ compile_fingerprint: "sf1:0123456789ab", container: { runtime_instances: [{ runtime: "daimon", node_ids: ["agent:alpha"] }] } })); execFileSync(process.execPath, ["scripts/compile-explicit-test-mcp.mjs", "--declaration", declaration, "--report", report, "--out", out]); const artifact = JSON.parse(await readFile(path.join(out, "explicit-test-mcp.json"))); const receipt = JSON.parse(await readFile(path.join(out, "explicit-test-mcp-receipt.json"))); assert.equal(artifact.compile_fingerprint, "sf1:0123456789ab"); assert.deepEqual(receipt.servers, [{ id: "fixture", agent_id: "agent:alpha", tools: ["checkpoint"] }]); } finally { await rm(root, { recursive: true, force: true }); } }); From d1eaddea39ecc3dcd063ef17184a3e869da9f4ea Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:50 +0200 Subject: [PATCH 14/34] docs: define operational runtime boundaries --- DAIMON_RUNTIME_MIGRATION_PLAN.md | 4 +- README.md | 14 ++- scripts/AGENTS.md | 40 ++++++- specs/COMPILER.md | 12 +- specs/CONTAINERS.md | 185 ++++++++++++++++++++++++++++++- specs/RUNTIMES.md | 30 +++-- specs/SPEC.md | 32 ++++-- 7 files changed, 286 insertions(+), 31 deletions(-) diff --git a/DAIMON_RUNTIME_MIGRATION_PLAN.md b/DAIMON_RUNTIME_MIGRATION_PLAN.md index 5ec1050b..7202fd50 100644 --- a/DAIMON_RUNTIME_MIGRATION_PLAN.md +++ b/DAIMON_RUNTIME_MIGRATION_PLAN.md @@ -9,8 +9,8 @@ continues to compile the organization graph, workspaces, Moltnet topology, schedules, credentials and deployment; Daimon owns every agent turn, engine process, engine authentication home, MCP lifecycle and process cleanup. -This is an ecosystem migration only. No Clank & Slop source, terminology, -personas, fixtures or publication behavior enters Spawnfile or Daimon. +This is an ecosystem migration only. No product-specific source, terminology, +personas, fixtures, or publication behavior enters the platform packages. ## Contract fixed by the current Daimon release diff --git a/README.md b/README.md index 8ed0a97d..0f0b0d62 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,11 @@ spawnfile status . --live # inspect the detached deployme spawnfile publish . --tag you/my-agent:1.0.0 # compile + build + verify + push ``` -Compiled output lands under `.spawn/` by default, including a `Dockerfile`, `entrypoint.sh`, `.env.example`, and a prebuilt `container/rootfs/` tree. `spawnfile build` uses the pinned runtime artifacts from `runtimes.yaml`; it does not rebuild runtimes from source. Daimon, OpenClaw, and PicoClaw use published copyable artifact images by default, so normal prompt/config edits reuse their dependency layers. Daimon accepts only its exact immutable image digest plus matching capability receipt; mutable/local overrides are fail-closed. OpenClaw and PicoClaw retain their explicit local-image overrides. For `build`/`up` on a docker `--context`, Moltnet release assets are staged for that context's architecture (`amd64` or `arm64`); for local-only manual compile targeting a fixed architecture, set `SPAWNFILE_MOLTNET_TARGET_ARCH=amd64|arm64`. +Compiled output lands under `.spawn/` by default, including a `Dockerfile`, `entrypoint.sh`, `.env.example`, and a prebuilt `container/rootfs/` tree. `spawnfile build` uses the pinned runtime artifacts from `runtimes.yaml`; it does not rebuild runtimes from source. Daimon, OpenClaw, and PicoClaw use published copyable artifact images by default, so normal prompt/config edits reuse their dependency layers. Daimon accepts its production manifest/receipt pins or an explicitly supplied generated non-production identity for the fixed loopback registry; raw, mutable, tag-only, or receipt-less overrides fail closed. OpenClaw and PicoClaw retain their explicit local-image overrides. For `build`/`up` on a docker `--context`, Moltnet release assets are staged for that context's architecture (`amd64` or `arm64`); for local-only manual compile targeting a fixed architecture, set `SPAWNFILE_MOLTNET_TARGET_ARCH=amd64|arm64`. `spawnfile status` is read-only. By default it shows authored and compiled state without Docker, runtime, or Moltnet calls. With `--live`, it reads the selected detached deployment record, inspects the recorded Docker target, runs adapter-owned runtime probes, and checks Moltnet metadata without reading message bodies. Add `--logs` for a redacted Docker log tail, or `--watch` to refresh status continuously. For a remote Docker context where the local record is missing, pass `--context ` with `--live` to recover the deployment from Spawnfile container labels. -`spawnfile dev` is the source-backed interactive loop. In Phase A, hot apply and its bounded activity buffer remain `runtime: pi` behavior; public `runtime: daimon` hosts do not yet support hot apply, schedules, MCP, or agent surfaces. A future control-plane adapter will integrate those concerns through Daimon's public APIs rather than generated Pi code. +`spawnfile dev` is the source-backed interactive loop. Hot apply remains runtime-specific. Public `runtime: daimon` v2 hosts support durable native cron/every/disabled schedules and authenticated Moltnet wake delivery; unsupported surfaces remain explicit in the capability report. Before automating Spawnfile, query the installed CLI rather than inferring support from its package version: @@ -220,6 +220,10 @@ The source-of-truth specs live in this repo: ## From source +The normal source build requires only Node.js 22+ and uses the checked-in, +checksum-verified Linux x64 and arm64 helper artifacts. It does not invoke +Docker or access the network. + ```bash git clone https://github.com/noopolis/spawnfile.git cd spawnfile @@ -229,6 +233,12 @@ npm run build npm link ``` +Maintainers rebuilding those native artifacts must additionally have Docker +with BuildKit, the pinned `gcc:14.2.0` builder image available, and QEMU/binfmt +enabled for both `linux/amd64` and `linux/arm64`. Run `npm run build:native`, +then `npm run build`; CI performs the same rebuild and syscall verification on +pull requests, `main`, and package publication. + For local development without linking globally: ```bash diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 3c6cb06b..069b959d 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -10,8 +10,14 @@ scripts/ ├── bootstrap-worktree.test.mjs # Bare node:test coverage and bootstrap self-test gate ├── build-closure.mjs # Builds a selected repository dependency closure in order ├── build-closure.test.mjs # Injected-registry and injected-run closure tests -├── build-local-daimon-runtime.mjs # Builds a digest-bound generic local Daimon image +├── build-local-daimon-runtime.mjs # Builds/pushes a receipt-bound Daimon image to an explicit loopback registry +├── build-local-daimon-runtime.test.mjs # AGY archive, provenance, redaction, and image-authority tests +├── create-source-provenance-bundle.mjs # Deterministic dirty-tree-safe all-input archive creator +├── create-linux-amd64-dependency-closure.mjs # Pinned-container lock/cache closure preparation +├── create-linux-amd64-go-closure.mjs # Pinned Go module-cache preparation for offline amd64 builds +├── source-provenance-bundle.mjs # Strict manifest, exclusion, ustar, digest, and drift checks ├── build-local-moltnet.mjs # Builds and stamps a local Moltnet release through Go +├── compile-explicit-test-mcp.mjs # Lowers bounded test-only MCP declarations against a compiled Daimon report ├── loop-verify.mjs # Runs mechanical loop gates and summarizes suite failures ├── loop-verify.test.mjs # Tests loop verification freshness and TAP parsing helpers ├── tap-self-test.mjs # Shared TAP parsing, test discovery, and case assertions @@ -29,6 +35,38 @@ scripts/ - Keep scripts on plain Node.js 22 ESM with zero third-party imports; use only node builtins and sibling modules. - Use named exports only and keep every source file below 400 lines. +- The local Daimon builder accepts the official AGY archive only through explicit + version, credential-free URL, SHA-512 archive, and SHA-256 extracted-executable + pins. It pushes only to the fixed loopback development repository and emits an + ignored immutable manifest/receipt identity; it currently fails closed outside + `linux/amd64` and never edits the runtime registry. +- Its public artifact inputs are `AGY_CLI_VERSION`, `AGY_CLI_URL`, + `AGY_CLI_SHA512`, `AGY_CLI_SHA256`, `GROK_CLI_VERSION`, `GROK_CLI_URL`, + `GROK_CLI_SHA256`, and `CODEX_CLI_SHA256`; URLs containing credentials, + queries, or fragments are rejected before Docker is invoked. +- The unchanged default Daimon source mode requires clean Git. Explicit archive + mode requires both `SPAWNFILE_DAIMON_SOURCE_BUNDLE` and + `SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE`; each is a strict deterministic ustar + created by `npm run bundle:source-provenance -- ` and + `npm run bundle:source-provenance -- --dependencies `. The first + root is the reviewed source tree; the dependency root contains exactly its + reviewed `package.json`, package-lock v3 graph, local package archives, and + npm cache prepared directly from the reviewed Daimon package/lock by `npm run + prepare:linux-amd64-closure -- + `. Docker runs `npm ci --offline`, checks every installed + package against the lock, then ships the pruned production closure. The target is always + `linux/amd64`, including from an arm64 host; arm64 output is not supported. + Docker verifies and consumes those exact bytes without Git metadata or npm + registry dependency resolution. Run `npm run test:source-provenance-docker` + for the mandatory network-disabled real-Docker archive gate. +- The unchanged local Moltnet default also requires clean Git. Dirty-tree archive + mode requires strict `--build-source` and `--go-dependencies` provenance + archives in `SPAWNFILE_MOLTNET_SOURCE_BUNDLE` and + `SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE`. Prepare the latter with + `npm run prepare:linux-amd64-go-closure -- `. + The pinned Go container verifies the module graph twice, the final build uses + `GOPROXY=off` and Docker `--network=none`, and the local stamp binds both + archive identities and the toolchain digest. - Clone node_modules with APFS clonefile, verify it against the worktree lock, and fall back to npm ci on any defect; never symlink it. - Preserve absolute paths and the literal WORKTREE prefix in bootstrap diagnostics. - Hook-bearing source repositories are discovered from their source `.githooks/` directory. Bootstrap copies missing hook directories as regular files, records the action, and verifies the target worktree's existing `core.hooksPath` without mutating shared git config. diff --git a/specs/COMPILER.md b/specs/COMPILER.md index 0d047470..86640e7a 100644 --- a/specs/COMPILER.md +++ b/specs/COMPILER.md @@ -341,7 +341,10 @@ Team networks are provider-backed organizational communication topology. Moltnet Rules: -- A parent team's `networks[].rooms[].members` list may name direct agent member IDs or direct child-team member IDs. +- A parent team's `networks[].rooms[].members` list may name direct agent member IDs, + direct child-team member IDs, or a scoped `:` member + backed by a pairing included in that room's federation stance. Scoped remote + members lower directly to Moltnet membership and never synthesize a local attachment. - Direct child-team IDs expand through the child team's representative chain, not to arbitrary descendants. - The compiler synthesizes Moltnet room attachments for selected representatives because the parent room is declared organization membership, not a proxy. - Moltnet member IDs are direct member slot IDs and must be unique across different canonical agent sources in the reachable nested team graph. Reusing the same member id is valid only when every duplicate resolves to the same canonical agent source. @@ -380,9 +383,14 @@ Rules: - `server.auth.public_read` and `server.auth.agent_registration` lower into native Moltnet auth config without changing generated node room authority. - Per-agent writable token paths are derived from the compiled agent slug and Moltnet member id so the generated `MoltnetNode` and generated `.moltnet/config.json` point to the same durable credential file. - Managed bearer `server.auth.client` requires `token_id`; the referenced server-level client or operator token must include `write` and at least one of `attach` or `observe`. -- Managed bearer `surfaces.moltnet[].auth.token_id` resolves independently per attachment. The referenced token must include `attach` and `write` and must bind exactly one `agents` entry equal to the attachment's resolved Moltnet member ID. +- Managed bearer `surfaces.moltnet[].auth.token_id` resolves independently per attachment. The referenced token must use exactly `[attach, write]`, or `[attach, observe, write]` for a Daimon attachment, and must bind exactly one `agents` entry equal to the attachment's resolved Moltnet member ID. +- A Daimon `MoltnetNode` attachment keeps the resolved Moltnet member ID in `agent.id` and emits the compiled Daimon host identity separately as `runtime.agent_id`; the bridge uses only the latter for Daimon wake requests and result matching. - Managed and external open static token mode requires `static_token: true` on the configured client source. - `server.pairings` entries are materialized into managed server config and rejected on non-managed networks. +- Relay pairing credentials lower independently to `pairings[].relay.token`; the pairing credential still lowers to `pairings[].token`. Both remain environment patches and are never rendered into source artifacts. +- `networks[].rooms[].federation` lowers to the native Moltnet room stance. + Lists are validated against the effective managed server pairings; omitted + stances lower to explicit `none` whenever pairings are configured. - Managed `server.human_ingress`, `server.direct_messages`, `server.debug_events`, `server.console.analytics`, `server.trust_forwarded_proto`, and `server.allowed_origins` lower directly into the Moltnet native server config. - `networks[].rooms[].visibility` and `networks[].rooms[].write_policy` lower directly into native Moltnet room config after representative expansion. Member expansion still controls concrete room membership; room write policy controls who may send. diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index 6b91b250..fa6262cd 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -141,9 +141,30 @@ noopolis/spawnfile-runtime-openclaw:2026.6.11 noopolis/spawnfile-runtime-picoclaw:0.3.1 ``` -Public Daimon hosts do not accept a local/tag-only image override. Recovery and -development use the separately released source-free generic image with the -same pinned digest and capability receipt; mutable checkout images fail closed. +Public Daimon hosts do not accept raw or tag-only image overrides. Standard +compiles always use the `runtimes.yaml` digest and receipt. Local development is +an explicit, fail-closed authority seam: `SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY` +must name an absolute generated identity file containing the exact non-production +stamp, an explicitly selected `127.0.0.1:/noopolis/spawnfile-runtime-daimon@sha256:`, and +the embedded capability-receipt SHA-256. Missing receipts, other repositories, +mutable tags, extensible identity documents, and legacy raw override variables +are rejected. The ignored identity file never updates `runtimes.yaml` and the +organization build still only copies the prebuilt runtime artifact. + +The local builder pins the official AGY Linux archive's manifest version, public +credential-free URL, and SHA-512 before extracting `antigravity`; it then verifies +the installed executable SHA-256. Its receipt also records those AGY fields and +the pinned Grok version, public URL, and SHA-256. Codex executable verification +remains mandatory. After building, the helper pushes only to the fixed loopback +development repository and records the returned OCI manifest digest. Until an +official AGY artifact exists for another architecture, this seam accepts only +`linux/amd64` and fails closed elsewhere. + +The helper requires explicit `AGY_CLI_VERSION`, `AGY_CLI_URL`, +`AGY_CLI_SHA512`, and extracted `AGY_CLI_SHA256` pins. It preserves the Grok +artifact's `GROK_CLI_URL`/`GROK_CLI_SHA256` pin and additionally requires +`GROK_CLI_VERSION` for provenance. `CODEX_CLI_SHA256` remains required. All +artifact URLs must be credential-free HTTPS URLs without query or fragment. OpenClaw and PicoClaw have equivalent overrides: @@ -288,7 +309,7 @@ Secret materialization rules: - `server.auth.tokens[].secret` is never written into source-controlled files. - `server.auth.tokens[].secret` is written into private Moltnet config values at runtime start. - `server.store.dsn_secret` is written as `storage.postgres.dsn` in managed server config. -- `server.pairings[].token_secret` is written as `pairings[].token` in managed server config. +- `server.pairings[].token_secret` is written as `pairings[].token` in managed server config. When a pairing uses the relay transport, `server.pairings[].relay.token_secret` is independently written as `pairings[].relay.token`. - Generated open-mode token files for attach/self-claiming clients are runtime state files with private permissions (equivalent to `0600`), and token directories use private directory mode (equivalent to `0700`). - Generated open-mode token directories are reported as persistent mounts so claimed agent identities survive container replacement. @@ -550,3 +571,159 @@ That harness SHOULD: - fail unless the expected sentinel reply is observed This harness is intentionally separate from `npm test` because it requires Docker, network access, and real credentials. +### Offline workspace bundles + +`workspace.resources` may declare a read-only `bundle` with `source`, exact +`sha256`, and `mount`. Compilation accepts only a bounded safe tar, copies its +exact bytes into the Docker context, and binds its digest into the resource +identity and generated entrypoint. The image therefore starts offline and the +normal build-context digest covers every tracked or untracked byte present in +the archive. Dependency artifacts needed at runtime belong inside that archive. + +Local production-candidate Daimon builds retain the clean-Git provenance mode +by default. A reviewed dirty integration tree instead uses two deterministic, +checksum-bound `spawnfile.source-input-manifest.v1` archives: one for source +(including intended untracked inputs) and one rooted at the exact installed +dependency tree. Creation excludes VCS metadata, secrets, generated output, +and caches, rejects escaping links, and rechecks the whole manifest after +reading. The amd64 Docker builder validates both archives and builds the npm +package from only those bytes; it neither copies Git metadata nor resolves +package dependencies from the network. The dependency archive is rooted at a +prepared package/lock/npm-cache closure with no installed `node_modules`; its manifest binds the +lock digest, required compiler/runtime packages, and exact `linux/amd64` +target. An arm64 host may drive this amd64 builder, but archive mode does not +produce an arm64 runtime image. Known credential files/directories are omitted +and credential-shaped file content fails creation before archive publication. + +Blue/green runs use distinct run-scoped volumes, including author-named +volumes. Product-state transfer is a separate explicit operation over a strict +`spawnfile.product-state-quiescence.v1` proof. Only listed regular files whose +checksums remain stable before and after copying are cloned. Auth, credential, +token, secret, session, wake, and SQLite paths are rejected; live volumes are +never mounted into the candidate. + +The mechanically executable workflow is `spawnfile product-state clone +request.json`. Its strict `spawnfile.product-state-clone-request.v1` names the +authority receipt, candidate-volume mountpoint, proof file, no-replace receipt +file, and candidate run id. `spawnfile product-state authority` first binds the +actual managed source container id, image, run label, start identity, exact +writable named-volume root, and candidate volume. It preserves an already +paused container; otherwise it pauses the whole container cgroup, proves the +pause, generates the complete source-tree proof, and restores the prior state. +Clone re-inspects every identity, pauses that same cgroup again, holds an +exclusive source fence, verifies before/copy/after checksums and the complete +manifest again, +atomically activates the candidate, and publishes a +`spawnfile.product-state-clone-receipt.v1`. Any copy or receipt failure removes +candidate output. Only a cgroup paused by Spawnfile is unpaused in `finally`; +an unrelated/restarted/reused container or rebound volume fails closed. + +### Generic canary, cutover, and rollback runbook + +The following is the complete operator workflow. The source proof MUST cover +the whole product-state tree, and `product-check` MUST be a project-owned, +read-only checker that emits `spawnfile.product-check-receipt.v1` with +`state:"passed"` and the candidate run id. No cutover command is run before +all three immutable receipts pass. + +The ingress adapter MUST atomically switch its provider and write the requested +strict `spawnfile.ingress-cutover-receipt.v1`: `state:"switched"`, exact from/to +deployment and target run id, a fresh 128-bit transaction `nonce`, plus `readiness_sha256` over the byte-exact up +receipt. Spawnfile validates it, tears the former deployment down, and only then +publishes the decision receipt. A durable transaction reservation makes retries +reconcile an already-switched ingress and idempotent teardown without switching +again. Candidate ports are read from both compiled +reports and must be unique and disjoint. + +A report containing a shared `exclusive-reattach` mount cannot use this +concurrent canary workflow. The rotating provider authority has one live +writer: replace under the same deployment identity, or stop the live +deployment while retaining volumes and then start the candidate so it +reattaches the same host-stable realm. + +```bash +set -euo pipefail +export PROJECT_PATH=/absolute/project +export CANDIDATE_PROJECT_PATH=/absolute/candidate-project-with-isolated-published-ports +export AUTH_PROFILE=production +export LIVE_DEPLOYMENT=live +export LIVE_RUN_ID=live-original-run-id +export LIVE_OUT=/absolute/live/build +export LIVE_TAG=local/live:attested +export LIVE_UP_RECEIPT=/absolute/live/up-receipt.json +export LIVE_IDENTITY=/absolute/live/deployment-identity.json +export CANDIDATE_DEPLOYMENT=candidate +export CANDIDATE_RUN_ID=candidate-20260825 +export CANDIDATE_OUT=/absolute/state/candidate-20260825/build +export CANDIDATE_TAG=local/candidate:candidate-20260825 +export CLONE_REQUEST=/absolute/state/candidate-20260825/clone-request.json +export AUTHORITY_REQUEST=/absolute/state/candidate-20260825/authority-request.json +export AUTHORITY_RECEIPT=/absolute/state/candidate-20260825/authority-receipt.json +export PROOF_PATH=/absolute/state/candidate-20260825/product-state-proof.json +export CLONE_RECEIPT=/absolute/state/candidate-20260825/clone-receipt.json +export UP_RECEIPT=/absolute/state/candidate-20260825/up-receipt.json +export PRODUCT_RECEIPT=/absolute/state/candidate-20260825/product-receipt.json +export DECISION_RECEIPT=/absolute/state/candidate-20260825/decision-receipt.json +export CUTOVER_REQUEST=/absolute/state/candidate-20260825/cutover-request.json +export INGRESS_RECEIPT=/absolute/state/candidate-20260825/ingress-receipt.json +export CANDIDATE_IDENTITY=/absolute/state/candidate-20260825/deployment-identity.json +export CUTOVER_TRANSACTION=/absolute/state/candidate-20260825/cutover-transaction.json +export LIVE_REPORT=/absolute/live/build/spawnfile-report.json +export LIVE_CONTAINER=live-container +export PRODUCT_MOUNT=/var/lib/product/state +export INGRESS_CUTOVER_EXECUTABLE=/absolute/operator/ingress-cutover +export CUTOVER_NONCE="$(openssl rand -hex 16)" +export LIVE_EXPORT=/absolute/state/candidate-20260825/exported-live +export CANDIDATE_EXPORT=/absolute/state/candidate-20260825/exported-candidate + +node -e 'const fs=require("fs"),e=process.env,p=e.LIVE_IDENTITY+".request";fs.writeFileSync(p,JSON.stringify({version:"spawnfile.deployment-identity-request.v1",readiness_path:e.LIVE_UP_RECEIPT,deployment_mode:"project",docker_command:"docker",receipt_path:e.LIVE_IDENTITY})+"\n",{flag:"wx",mode:0o600})' +spawnfile canary identity "$LIVE_IDENTITY.request" + +NOOPOLIS_RUN_ID="$CANDIDATE_RUN_ID" spawnfile build "$CANDIDATE_PROJECT_PATH" --out "$CANDIDATE_OUT" --tag "$CANDIDATE_TAG" +node -e 'const [l,c]=process.argv.slice(1).map(require),a=new Set(l.published_ports||[]),b=c.published_ports||[];if(new Set(b).size!==b.length||b.some(p=>a.has(p)))process.exit(1)' "$LIVE_REPORT" "$CANDIDATE_OUT/spawnfile-report.json" +BUILD_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$CANDIDATE_TAG")" +export CANDIDATE_VOLUME_NAME="$(node -e 'const r=require(process.argv[1]),m=(r.persistent_mounts||[]).filter(x=>x.mount_path===process.argv[2]);if(m.length!==1||!m[0].volume_name)process.exit(1);process.stdout.write(m[0].volume_name)' "$CANDIDATE_OUT/spawnfile-report.json" "$PRODUCT_MOUNT")" +export CANDIDATE_RESOURCE_IDENTITY="$(node -e 'const r=require(process.argv[1]),m=(r.workspace_resources||[]).filter(x=>x.backing_path===process.argv[2]);if(m.length!==1||!m[0].resolved_identity)process.exit(1);process.stdout.write(m[0].resolved_identity)' "$CANDIDATE_OUT/spawnfile-report.json" "$PRODUCT_MOUNT")" +docker volume create "$CANDIDATE_VOLUME_NAME" >/dev/null +export CANDIDATE_VOLUME_PATH="$(docker volume inspect --format '{{.Mountpoint}}' "$CANDIDATE_VOLUME_NAME")" +node -e 'const fs=require("fs"),e=process.env;fs.writeFileSync(e.AUTHORITY_REQUEST,JSON.stringify({version:"spawnfile.product-state-source-authority-request.v1",docker_command:"docker",container:e.LIVE_CONTAINER,source_run_id:e.LIVE_RUN_ID,mount_path:e.PRODUCT_MOUNT,candidate_volume_name:e.CANDIDATE_VOLUME_NAME,candidate_resource_identity:e.CANDIDATE_RESOURCE_IDENTITY,receipt_path:e.AUTHORITY_RECEIPT,proof_path:e.PROOF_PATH})+"\n",{flag:"wx",mode:0o600})' +spawnfile product-state authority "$AUTHORITY_REQUEST" >/dev/null +node -e 'const fs=require("fs"),e=process.env;fs.writeFileSync(e.CLONE_REQUEST,JSON.stringify({version:"spawnfile.product-state-clone-request.v1",authority_receipt_path:e.AUTHORITY_RECEIPT,docker_command:"docker",destination:e.CANDIDATE_VOLUME_PATH,proof_path:e.PROOF_PATH,receipt_path:e.CLONE_RECEIPT,candidate_run_id:e.CANDIDATE_RUN_ID})+"\n",{flag:"wx",mode:0o600})' +spawnfile product-state clone "$CLONE_REQUEST" > /dev/null +NOOPOLIS_RUN_ID="$CANDIDATE_RUN_ID" spawnfile up "$CANDIDATE_PROJECT_PATH" --out "$CANDIDATE_OUT" --tag "$CANDIDATE_TAG" --deployment "$CANDIDATE_DEPLOYMENT" --auth-profile "$AUTH_PROFILE" --detach --json > "$UP_RECEIPT" +product-check --run-id "$CANDIDATE_RUN_ID" --deployment "$CANDIDATE_DEPLOYMENT" > "$PRODUCT_RECEIPT" +node -e 'const fs=require("fs"),cp=require("child_process");const [c,u,p,r,image]=process.argv.slice(1),C=JSON.parse(fs.readFileSync(c)),U=JSON.parse(fs.readFileSync(u)),P=JSON.parse(fs.readFileSync(p)),ids=U.deployment?.container_ids;if(C.version!=="spawnfile.product-state-clone-receipt.v1"||C.candidate_run_id!==r||U.version!=="spawnfile.up-receipt.v1"||U.organization_ready?.state!=="ready"||U.organization_ready?.run_id!==r||!Array.isArray(ids)||ids.length!==1||cp.execFileSync("docker",["inspect","--format","{{.Image}}",ids[0]],{encoding:"utf8"}).trim()!==image||P.version!=="spawnfile.product-check-receipt.v1"||P.state!=="passed"||P.run_id!==r)process.exit(1)' "$CLONE_RECEIPT" "$UP_RECEIPT" "$PRODUCT_RECEIPT" "$CANDIDATE_RUN_ID" "$BUILD_IMAGE_ID" +node -e 'const fs=require("fs"),e=process.env,p=e.CANDIDATE_IDENTITY+".request";fs.writeFileSync(p,JSON.stringify({version:"spawnfile.deployment-identity-request.v1",readiness_path:e.UP_RECEIPT,deployment_mode:"project",docker_command:"docker",receipt_path:e.CANDIDATE_IDENTITY})+"\n",{flag:"wx",mode:0o600})' +spawnfile canary identity "$CANDIDATE_IDENTITY.request" +node -e 'const fs=require("fs"),e=process.env;fs.writeFileSync(e.CUTOVER_REQUEST,JSON.stringify({version:"spawnfile.canary-cutover-request.v1",live_report_path:e.LIVE_REPORT,candidate_report_path:e.CANDIDATE_OUT+"/spawnfile-report.json",readiness_path:e.UP_RECEIPT,expected_identity:JSON.parse(fs.readFileSync(e.CANDIDATE_IDENTITY)),docker_command:"docker",nonce:e.CUTOVER_NONCE,transaction_path:e.CUTOVER_TRANSACTION,ingress_command:e.INGRESS_CUTOVER_EXECUTABLE,ingress_args:["--from",e.LIVE_DEPLOYMENT,"--to",e.CANDIDATE_DEPLOYMENT,"--nonce",e.CUTOVER_NONCE,"--require-up-receipt",e.UP_RECEIPT,"--receipt",e.INGRESS_RECEIPT],ingress_receipt_path:e.INGRESS_RECEIPT,teardown_command:"spawnfile",teardown_args:["down",e.PROJECT_PATH,"--compiled",e.LIVE_OUT,"--deployment",e.LIVE_DEPLOYMENT,"--export-to",e.LIVE_EXPORT,"--json","--lifecycle-invocation","lci_canary_"+e.CUTOVER_NONCE],teardown_policy:"export",teardown_project_path:e.PROJECT_PATH,teardown_compiled_path:e.LIVE_OUT,decision_receipt_path:e.DECISION_RECEIPT,from_deployment:e.LIVE_DEPLOYMENT,to_deployment:e.CANDIDATE_DEPLOYMENT})+"\n",{flag:"wx",mode:0o600})' +spawnfile canary cutover "$CUTOVER_REQUEST" +``` + +Pre-cutover rollback re-attests/rebinds ingress to the already-live deployment +and tears down only the fresh candidate through the same receipt-gated transaction: + +```bash +set -euo pipefail +export ABORT_NONCE="$(openssl rand -hex 16)" +export ABORT_INGRESS_RECEIPT="$DECISION_RECEIPT.abort-ingress" +export ABORT_TRANSACTION="$DECISION_RECEIPT.abort-transaction" +export ABORT_DECISION="$DECISION_RECEIPT.abort-decision" +node -e 'const fs=require("fs"),e=process.env,p=e.ABORT_DECISION+".request";fs.writeFileSync(p,JSON.stringify({version:"spawnfile.canary-cutover-request.v1",live_report_path:e.CANDIDATE_OUT+"/spawnfile-report.json",candidate_report_path:e.LIVE_REPORT,readiness_path:e.LIVE_UP_RECEIPT,expected_identity:JSON.parse(fs.readFileSync(e.LIVE_IDENTITY)),docker_command:"docker",nonce:e.ABORT_NONCE,transaction_path:e.ABORT_TRANSACTION,ingress_command:e.INGRESS_CUTOVER_EXECUTABLE,ingress_args:["--from",e.CANDIDATE_DEPLOYMENT,"--to",e.LIVE_DEPLOYMENT,"--nonce",e.ABORT_NONCE,"--require-up-receipt",e.LIVE_UP_RECEIPT,"--receipt",e.ABORT_INGRESS_RECEIPT],ingress_receipt_path:e.ABORT_INGRESS_RECEIPT,teardown_command:"spawnfile",teardown_args:["down",e.CANDIDATE_PROJECT_PATH,"--compiled",e.CANDIDATE_OUT,"--deployment",e.CANDIDATE_DEPLOYMENT,"--export-to",e.CANDIDATE_EXPORT,"--json","--lifecycle-invocation","lci_canary_"+e.ABORT_NONCE],teardown_policy:"export",teardown_project_path:e.CANDIDATE_PROJECT_PATH,teardown_compiled_path:e.CANDIDATE_OUT,decision_receipt_path:e.ABORT_DECISION,from_deployment:e.CANDIDATE_DEPLOYMENT,to_deployment:e.LIVE_DEPLOYMENT})+"\n",{flag:"wx",mode:0o600})' +spawnfile canary cutover "$ABORT_DECISION.request" +``` + +Post-cutover rollback removes only the candidate and restarts the exact +attested prior image with its original run identity; retained prior volumes +remain isolated and are never mounted by the candidate: + +```bash +set -euo pipefail +export ROLLBACK_UP_RECEIPT="$DECISION_RECEIPT.rollback-readiness" +export ROLLBACK_NONCE="$(openssl rand -hex 16)" +export ROLLBACK_INGRESS_RECEIPT="$DECISION_RECEIPT.rollback-ingress" +export ROLLBACK_TRANSACTION="$DECISION_RECEIPT.rollback-transaction" +export ROLLBACK_DECISION="$DECISION_RECEIPT.rollback-decision" +NOOPOLIS_RUN_ID="$LIVE_RUN_ID" spawnfile up "$PROJECT_PATH" --out "$LIVE_OUT" --tag "$LIVE_TAG" --deployment "$LIVE_DEPLOYMENT" --auth-profile "$AUTH_PROFILE" --detach --json > "$ROLLBACK_UP_RECEIPT" +node -e 'const fs=require("fs"),e=process.env,p=e.ROLLBACK_DECISION+".request";fs.writeFileSync(p,JSON.stringify({version:"spawnfile.canary-cutover-request.v1",live_report_path:e.CANDIDATE_OUT+"/spawnfile-report.json",candidate_report_path:e.LIVE_REPORT,readiness_path:e.ROLLBACK_UP_RECEIPT,expected_identity:JSON.parse(fs.readFileSync(e.LIVE_IDENTITY)),docker_command:"docker",nonce:e.ROLLBACK_NONCE,transaction_path:e.ROLLBACK_TRANSACTION,ingress_command:e.INGRESS_CUTOVER_EXECUTABLE,ingress_args:["--from",e.CANDIDATE_DEPLOYMENT,"--to",e.LIVE_DEPLOYMENT,"--nonce",e.ROLLBACK_NONCE,"--require-up-receipt",e.ROLLBACK_UP_RECEIPT,"--receipt",e.ROLLBACK_INGRESS_RECEIPT],ingress_receipt_path:e.ROLLBACK_INGRESS_RECEIPT,teardown_command:"spawnfile",teardown_args:["down",e.CANDIDATE_PROJECT_PATH,"--compiled",e.CANDIDATE_OUT,"--deployment",e.CANDIDATE_DEPLOYMENT,"--export-to",e.CANDIDATE_EXPORT,"--json","--lifecycle-invocation","lci_canary_"+e.ROLLBACK_NONCE],teardown_policy:"export",teardown_project_path:e.CANDIDATE_PROJECT_PATH,teardown_compiled_path:e.CANDIDATE_OUT,decision_receipt_path:e.ROLLBACK_DECISION,from_deployment:e.CANDIDATE_DEPLOYMENT,to_deployment:e.LIVE_DEPLOYMENT})+"\n",{flag:"wx",mode:0o600})' +spawnfile canary cutover "$ROLLBACK_DECISION.request" +``` diff --git a/specs/RUNTIMES.md b/specs/RUNTIMES.md index 17eedfd5..97e948d9 100644 --- a/specs/RUNTIMES.md +++ b/specs/RUNTIMES.md @@ -94,7 +94,7 @@ Support levels: | `workspace.resources` `volume` | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory per concrete agent workspace | | `workspace.resources` `git` | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | | `environment.env`, `environment.secrets`, `environment.packages` | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | -| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Rejected in Phase A; the public organization config has no MCP field | +| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Supported for explicit nonempty tool allowlists; stdio commands must be absolute and remote bearer secrets remain env-name references | | `memory` | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Degraded/declared only in Phase A; no Spawnfile memory lowering enters the public config | | `execution.sandbox.mode` | Supported through OpenClaw runtime/container workspace behavior | Supported through `restrict_to_workspace` and container workspace behavior | Degraded; the generic runtime image and physical roots provide isolation | | `subagents` | Degraded; routed sessions do not preserve full parent-owned semantics | Supported through PicoClaw subagent behavior | Degraded; the public host runs listed agents independently | @@ -107,9 +107,10 @@ Support levels: | Anthropic `api_key` auth | Supported | Supported | Rejected | | Anthropic `claude-code` auth | Supported | Supported | Rejected | | `custom` or `local` endpoint | Supported except subscription-import auth | Supported for compatible endpoint/auth pairs | Rejected | -| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Rejected in Phase A | -| `schedule.kind: every` | Degraded | Degraded | Rejected in Phase A | -| `surfaces.moltnet` | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge | Supported through Daimon's authenticated public `/v1/wake` control bridge when the selected Moltnet release declares `daimon-bridge`; a public pi-only release fails closed | +| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Supported through the durable native v2 scheduler | +| `schedule.kind: every` | Degraded | Degraded | Supported through the durable native v2 scheduler | +| `schedule.kind: disabled` | Supported, emits no wake registration | Supported, emits no wake registration | Supported, emits no wake registration | +| `surfaces.moltnet` | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge | Supported for inbound authenticated wakes and an outbound cognition tool constrained to compiled networks, rooms, and DM policy; sends carry deterministic delivery ids and durable per-agent receipts | | Discord, Telegram, WhatsApp, Slack | Supported with OpenClaw access-mode coverage | Partial: open and user allowlists; pairing and richer allowlists rejected | Rejected | | Webhook | Parsed, not lowered by active adapters in v0.1 | Parsed, not lowered by active adapters in v0.1 | Rejected | @@ -158,15 +159,24 @@ the `runtime: daimon` public-host contract and must not be inferred from it. ### Daimon opaque auth ownership Daimon credential inputs are opaque local bind sources, not Spawnfile auth -files. Spawnfile authorizes their filesystem metadata only and passes a -nonzero common owner UID through the in-memory launch path; it never reads or -interprets credential bytes and never creates an engine home. The generated -container wrapper uses that UID only to prepare compiler-owned writable state, -then Daimon materializes its own private engine artifact after privilege drop. +files. Spawnfile authorizes their filesystem metadata and validates only the +bounded provider-native refresh shape without retaining or reporting bytes. +It passes a nonzero common owner UID through the in-memory launch path and +never creates an engine credential home. The generated container wrapper uses +that UID only to prepare compiler-owned writable state. Remote, SSH, and user-namespace-remapped Docker targets are unsupported when an opaque Daimon source is present. -The consumed Daimon manifest declares opaque file slots for Codex and Grok. +The consumed Daimon manifest declares one per-agent opaque slot for Codex. +For Grok it declares one durable rotating-credential realm and one read-only +operator bootstrap slot; Daimon serializes Grok turns through that authority, +atomically reconciles provider rotation, and leaves sessions/cache per agent. +The generated Linux container installs `bubblewrap`, which Grok requires to +fail closed while applying the realm and peer-home deny set. +The realm mount is `exclusive-reattach`: its host-stable volume survives run +and deployment identities, cannot be attached by two live deployments, and is +never copied by product-state migration. Standard concurrent canary is +rejected; stop the old deployment and reattach the same realm for replacement. For AGY it declares one host-realm durable mount plus one independent opaque unlock source slot. Spawnfile emits the stable RW volume, metadata-authorizes the caller-owned `0600` unlock source, and mounts it read-only; it never reads diff --git a/specs/SPEC.md b/specs/SPEC.md index 86f94228..65629dae 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -520,9 +520,10 @@ runtime: For `runtime.name: daimon`, `runtime.options.engine` MAY be `codex`, `grok`, or `agy`. If omitted, the compiler MUST use `codex`. Spawnfile emits one strict `noopolis.daimon.organization-runtime.v1` host config and never generates engine -argv, auth, or Pi code for it. In Phase A, schedules, MCP declarations, and all -agent surfaces MUST be rejected. `runtime: pi` is the separate legacy generated -Pi implementation and retains its own engine/auth/scheduler/MCP/Moltnet behavior. +argv, auth, or Pi code for it. Strict organization-runtime v2 configs lower +`cron`, `every`, and `disabled` schedules into Daimon's durable native scheduler +and may attach authenticated Moltnet delivery through the declared bridge. +Unsupported MCP and agent surfaces remain rejected or explicitly reported. ### 2.5 Execution Intent @@ -690,7 +691,7 @@ Rules: - `surfaces.slack.identity.user_id` is OPTIONAL. If present, it is the Slack user ID advertised in generated rosters where this agent is visible. - Surface `identity` fields are opt-in roster metadata. They do not provision accounts, validate provider-side membership, or cause Spawnfile to read runtime state. - `surfaces.moltnet` is a list of Moltnet attachments. Each attachment MUST declare `network` and at least one of `rooms` or `dms`. -- `surfaces.moltnet[].auth.token_id` is OPTIONAL and is valid only for a managed bearer network. It MUST reference one declared server token whose scopes include `attach` and `write` and whose `agents` list contains exactly that attachment's resolved Moltnet member ID. +- `surfaces.moltnet[].auth.token_id` is OPTIONAL and is valid only for a managed bearer network. It MUST reference one declared server token whose scopes include `attach` and `write` and whose `agents` list contains exactly that attachment's resolved Moltnet member ID. A Daimon attachment additionally requires `observe` so its bridge can receive network events. - Moltnet room and DM `wake` policy MAY be `all`, `mentions`, `thread_only`, or `never`. - Moltnet room and DM `reply` policy MAY be only `auto` or `never` in this alpha. `manual` is not part of the portable v0.1 contract. - Moltnet attachments are valid only when the agent participates in a team context whose `team.networks[]` declares the named network and rooms. @@ -931,7 +932,8 @@ Rules: - `every` schedules MUST declare a non-empty `every` interval. - `every` intervals use explicit duration strings such as `15m`, `2h`, or `1d`. - `timezone` defaults to `UTC` when omitted. -- `cron` and `every` schedules MAY declare `timezone` and `prompt`. +- `cron` schedules MUST declare `timezone` and MAY declare `prompt`. +- `every` schedules MAY declare `prompt` and MUST NOT declare `timezone`. - `disabled` schedules MUST NOT declare `cron`, `every`, `timezone`, or `prompt` fields. - A `disabled` schedule MUST not emit a spawn or wake registration. - Team manifests MUST NOT declare `schedule`. @@ -1287,8 +1289,14 @@ Rules: - A managed bearer attachment that declares `surfaces.moltnet[].auth.token_id` MUST use its referenced per-attachment token instead of `server.auth.client`. That token MUST include `attach` and `write` and MUST declare exactly `agents: []`. - `server.auth.mode: none` rejects all token sources. - `server.pairings` is valid only for `server.mode: managed`. -- Managed `server.pairings` entries use `id` and MAY include `remote_network_id`, `remote_network_name`, `remote_base_url`, and `token_secret`. +- Managed `server.pairings` entries use `id`, `remote_network_id`, `remote_network_name`, and `token_secret`, plus exactly one transport: `remote_base_url`, or `relay` with `url`, `room`, and `token_secret`. Relay and pairing credentials are independent secret references. - `server.pairings.id` MUST be unique within one managed server block. +- `rooms[].federation` is OPTIONAL and MUST be `none`, `all`, or a non-empty + list of pairing ids. Pairing ids in a list MUST resolve against the effective + managed server; external-server rooms MUST NOT declare federation. +- An omitted room federation stance means `none`. When a managed server has + pairings, the compiler MUST emit that default explicitly so adding a pairing + never grants an existing room access implicitly. - `server.store` MUST be present for `server.mode: managed`. - `server.store.kind` MUST be `sqlite`, `json`, `postgres`, or `memory`. - `server.store.kind: sqlite` and `server.store.kind: json` MAY omit `path`; omitted paths default under `/var/lib/spawnfile/moltnet/networks//`. @@ -1307,7 +1315,9 @@ Rules: - `server.direct_messages: false` means any `surfaces.moltnet[].dms` for that network is a validation error. - `server.debug_events: true` is valid only for managed Moltnet servers and lowers to Moltnet lifecycle diagnostics. It can expose disconnect reasons and bridge/runtime errors through events, so it is intended for operational debugging, not normal public network defaults. - `server.console.analytics` is valid only for managed Moltnet servers and configures the hosted `/console/` page. In v0.1 the only supported provider is `google`, with a GA4 `measurement_id` such as `G-XXXXXXXXXX`. -- A room `members` list MAY name direct agent member IDs or direct child-team member IDs. +- A room `members` list MAY name direct agent member IDs, direct child-team member IDs, + or a scoped `:` member whose remote network is a + declared pairing included by that room's federation stance. - A room `visibility` is OPTIONAL and MUST be `public` or `private` when present. Public visibility only becomes anonymous-readable when `server.auth.public_read: true`. - A room `write_policy` is OPTIONAL and MUST be `members`, `registered_agents`, or `operators` when present. Generated agent tokens are identity tokens; they do not bypass `members` or `operators` policies. - Direct child-team IDs in a parent room expand to the child team's concrete representatives for that parent context. @@ -1360,9 +1370,11 @@ Rules: operator token MUST have no `agents` binding and MUST have exactly the ordered scopes `[admin, observe, write]`. - Every agent or service actor on that network MUST explicitly select its own - token. An actor token MUST have exactly the ordered scopes `[attach, write]`, - MUST bind exactly one canonical member ID in `agents`, and MUST be selected by - exactly that actor. Operator and actor token IDs and secret environment + token. A non-Daimon agent token MUST have exactly the ordered scopes + `[attach, write]`; a Daimon agent token MUST have exactly + `[attach, observe, write]`; and an external service token MAY use either + ordered set. Every actor token MUST bind exactly one canonical member ID in + `agents` and MUST be selected by exactly that actor. Operator and actor token IDs and secret environment identities MUST remain distinct; no actor may select the operator token. - The compiler MUST treat token `secret` values as environment-variable identities only. It MUST NOT read or serialize the referenced credential From 88bcdf977f29e43e07378da50e30244cbe3d93d4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:54 +0200 Subject: [PATCH 15/34] docs(website): explain autonomous runtime deployment --- .../docs/guides/writing-a-spawnfile.md | 6 ++--- website/src/content/docs/runtimes/daimon.md | 22 +++++++------------ website/src/content/docs/runtimes/overview.md | 6 ++--- website/src/content/docs/spec/compiler.md | 3 ++- website/src/content/docs/spec/containers.md | 18 +++++++-------- website/src/content/docs/spec/runtimes.md | 7 +++--- website/src/content/docs/spec/spec.md | 4 +++- 7 files changed, 30 insertions(+), 36 deletions(-) diff --git a/website/src/content/docs/guides/writing-a-spawnfile.md b/website/src/content/docs/guides/writing-a-spawnfile.md index d233800c..f810eba6 100644 --- a/website/src/content/docs/guides/writing-a-spawnfile.md +++ b/website/src/content/docs/guides/writing-a-spawnfile.md @@ -88,13 +88,11 @@ Supported forms: - `kind: every` with a non-empty interval such as `2h` or `24h`. - `kind: disabled` to declare that automatic wake is intentionally off. -`cron` and `every` schedules may include `timezone` and `prompt`. The prompt should describe one bounded wake iteration and usually complements `workspace.docs.heartbeat`. +`cron` schedules require `timezone` and may include `prompt`; `every` schedules may include `prompt` but not `timezone`. The prompt should describe one bounded wake iteration and usually complements `workspace.docs.heartbeat`. Schedule declarations are portable wake intent. In this alpha, runtimes may report schedule lowering as degraded when they validate the intent but do not emit a native scheduler yet. -In v0.1, `schedule.kind: cron` lowers to native scheduler artifacts for PicoClaw. -`schedule.kind: every` lowers to the generated in-process scheduler for Pi. -Other runtime/schedule combinations are validated but may report as degraded. +PicoClaw lowers cron to its native cron store. Daimon lowers cron, every, and disabled into its strict v2 durable native scheduler. Other runtime/schedule combinations may report as degraded. ## skills diff --git a/website/src/content/docs/runtimes/daimon.md b/website/src/content/docs/runtimes/daimon.md index 692b476a..c9e2a114 100644 --- a/website/src/content/docs/runtimes/daimon.md +++ b/website/src/content/docs/runtimes/daimon.md @@ -79,9 +79,7 @@ execution: ## Schedule Handling -Daimon supports `schedule.kind: every` through the generated harness app. The app owns a small in-process scheduler, queues a wake when an agent is already busy, and invokes the agent again after the current turn finishes. - -`schedule.kind: cron` is validated but reported as degraded for Daimon in v0.1. Use PicoClaw when a native cron store is required. +Daimon's strict v2 organization runtime supports `schedule.kind: cron`, `every`, and `disabled`. Cron uses the declared IANA timezone and standard five-field DOM/DOW semantics; every uses a durable anchor and interval. The runtime persists stable occurrence identities beside its acceptance store, coalesces downtime and busy periods to the latest eligible occurrence, and deduplicates execution across restart. Disabled schedules register no timer or wake. ## Sandbox Handling @@ -97,7 +95,7 @@ Workspace resources use the same container lifecycle as other runtimes: - shared team resources are visible from each agent workspace through symlinks - `git` resources are prepared at container startup rather than during compile -MCP server declarations are validated but reported as degraded for Daimon in v0.1 because the generated app does not lower MCP servers into Pi yet. +MCP server declarations require an explicit nonempty `tools` allowlist. Spawnfile lowers the per-agent server authority into the organization config; Daimon verifies the listed tools at startup and exposes only those tools to real Codex/Grok cognition turns. Stdio commands must be absolute, remote bearer credentials remain environment-name references, and calls are bounded with durable per-agent receipts. ## Memory Handling @@ -178,16 +176,12 @@ For container compilation: - Config, home, and workspace paths under `/var/lib/spawnfile/instances/daimon/pi-app` - A start command that runs the generated app -Daimon uses `noopolis/spawnfile-runtime-daimon:0.1.2` by default. To test a -local runtime artifact instead: - -```bash -git clone git@github.com:noopolis/daimon.git -cd daimon -npm run image:runtime:local -SPAWNFILE_DAIMON_RUNTIME_IMAGE=noopolis/spawnfile-runtime-daimon:0.1.2-local \ - spawnfile build ./agentic-org -``` +Daimon uses the immutable image manifest and capability-receipt digests pinned +in `runtimes.yaml` by default. A local runtime can be selected only through the +generated identity file named by `SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY`. +That non-production identity must bind the fixed loopback registry repository +by manifest digest and include the exact embedded receipt digest; raw image, +tag-only, missing-receipt, and arbitrary-registry overrides are rejected. Unlike a runtime-specific base image, this artifact image works for mixed-runtime organizations. Generated Dockerfiles copy Daimon from the artifact diff --git a/website/src/content/docs/runtimes/overview.md b/website/src/content/docs/runtimes/overview.md index 62033ed6..734ab7a0 100644 --- a/website/src/content/docs/runtimes/overview.md +++ b/website/src/content/docs/runtimes/overview.md @@ -127,7 +127,7 @@ Support levels: | `workspace.resources` `git` | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | | `environment.env` and `environment.secrets` | Compiler-owned env and secret materialization | Compiler-owned env and secret materialization | Compiler-owned env and secret materialization | | `environment.packages` | Compiler-owned container package installation | Compiler-owned container package installation | Compiler-owned container package installation | -| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Degraded; not lowered into the generated Daimon app yet | +| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Supported for explicit tool allowlists; stdio commands must be absolute | | `memory` | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported through Mneme; `engine: pi` uses in-process tools and CLI engines receive pre-turn recall context only | | `execution.sandbox.mode` | Supported through OpenClaw runtime/container workspace behavior | Supported through `restrict_to_workspace` and container workspace behavior | Degraded; container/workspace isolation only, Pi itself is not a sandbox engine | | `subagents` | Degraded; lowered to routed sessions, not full Spawnfile parent-owned semantics | Supported through PicoClaw subagent behavior | Degraded; grouped app agents exist, but parent-owned subagent semantics are not preserved | @@ -141,8 +141,8 @@ Support levels: | Anthropic `api_key` auth | Supported | Supported | Supported | | Anthropic `claude-code` auth | Supported | Supported | Supported through Pi's Anthropic OAuth auth store | | `custom` or `local` endpoint with `api_key` or `none` | Supported, except subscription-import auth | Supported for supported endpoint/auth combinations | Supported through generated Pi `models.json` | -| `schedule.kind: cron` | Degraded; no OpenClaw schedule store is emitted in v0.1 | Supported through `workspace/cron/jobs.json` | Degraded; the generated app only supports interval schedules | -| `schedule.kind: every` | Degraded; no OpenClaw schedule store is emitted in v0.1 | Degraded; PicoClaw lowering is cron-only in v0.1 | Supported by the generated app scheduler | +| `schedule.kind: cron` | Degraded; no OpenClaw schedule store is emitted in v0.1 | Supported through `workspace/cron/jobs.json` | Supported through the durable native v2 scheduler | +| `schedule.kind: every` | Degraded; no OpenClaw schedule store is emitted in v0.1 | Degraded; PicoClaw lowering is cron-only in v0.1 | Supported through the durable native v2 scheduler | | `schedule.kind: disabled` | Supported, emits no wake registration | Supported, emits no wake registration | Supported, emits no wake registration | ### Communication Surface diff --git a/website/src/content/docs/spec/compiler.md b/website/src/content/docs/spec/compiler.md index ed1dbe02..11aa8071 100644 --- a/website/src/content/docs/spec/compiler.md +++ b/website/src/content/docs/spec/compiler.md @@ -379,7 +379,8 @@ Rules: - `open`: emits `auth_mode: open`, `registration: open`, and per-agent writable token paths unless a static token client source is provided. - `server.auth.public_read` and `server.auth.agent_registration` lower into native Moltnet auth config without changing generated node room authority. - Per-agent writable token paths are derived from the compiled agent slug and Moltnet member id so the generated `MoltnetNode` and generated `.moltnet/config.json` point to the same durable credential file. -- Managed bearer mode requires `token_id` and requires the referenced token to include `attach` and `write` scopes. +- Managed bearer attachment `token_id` references must use exactly `[attach, write]`, or `[attach, observe, write]` for a Daimon attachment, and bind exactly that attachment's resolved Moltnet member ID. +- A Daimon `MoltnetNode` attachment keeps the resolved Moltnet member ID in `agent.id` and emits the compiled Daimon host identity separately as `runtime.agent_id`; the bridge uses only the latter for Daimon wake requests and result matching. - Managed and external open static token mode requires `static_token: true` on the configured client source. - `server.pairings` entries are materialized into managed server config and rejected on non-managed networks. - Managed `server.human_ingress`, `server.direct_messages`, `server.debug_events`, `server.console.analytics`, `server.trust_forwarded_proto`, and `server.allowed_origins` lower directly into the Moltnet native server config. diff --git a/website/src/content/docs/spec/containers.md b/website/src/content/docs/spec/containers.md index a805c9cd..2a4877c4 100644 --- a/website/src/content/docs/spec/containers.md +++ b/website/src/content/docs/spec/containers.md @@ -137,22 +137,20 @@ Runtimes MAY provide a reusable artifact image that already contains their pinne The Daimon, OpenClaw, and PicoClaw adapters use published runtime artifact images by default. Generated Dockerfiles copy each runtime from `/opt/spawnfile/runtime-installs/` and skip runtime npm/archive installs during organization builds. -Current default images: +Current default images include an immutable Daimon artifact: ```text -noopolis/spawnfile-runtime-daimon:0.1.2 +noopolis/spawnfile-runtime-daimon@sha256: noopolis/spawnfile-runtime-openclaw:2026.6.11 noopolis/spawnfile-runtime-picoclaw:0.3.1 ``` -To test a local Daimon runtime artifact instead: - -```bash -git clone git@github.com:noopolis/daimon.git -cd daimon -npm run image:runtime:local -SPAWNFILE_DAIMON_RUNTIME_IMAGE=noopolis/spawnfile-runtime-daimon:0.1.2-local spawnfile up ./org --detach -``` +Standard Daimon compiles use the exact manifest and capability-receipt digests +in `runtimes.yaml`. Local development requires an absolute generated identity +path in `SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY`. The identity is accepted only +for `127.0.0.1:5000/noopolis/spawnfile-runtime-daimon@sha256:` and must +contain the matching receipt digest plus the exact non-production stamp. Raw, +tag-only, receipt-less, or arbitrary-registry overrides fail closed. OpenClaw and PicoClaw have equivalent overrides: diff --git a/website/src/content/docs/spec/runtimes.md b/website/src/content/docs/spec/runtimes.md index 21e6ee95..dd9b4e17 100644 --- a/website/src/content/docs/spec/runtimes.md +++ b/website/src/content/docs/spec/runtimes.md @@ -99,7 +99,7 @@ Support levels: | `workspace.resources` `volume` | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory | Compiler-owned symlink/backing directory per concrete agent workspace | | `workspace.resources` `git` | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | Compiler-owned clone/link at container startup | | `environment.env`, `environment.secrets`, `environment.packages` | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | Compiler-owned container/startup behavior | -| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Degraded; not lowered into the generated Daimon app yet | +| `environment.mcp_servers` | Supported through OpenClaw `mcp.servers` config | Supported through PicoClaw MCP config | Supported for explicit tool allowlists; stdio commands must be absolute | | `memory` | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported for file-backed banks through compiler-generated Mneme MCP servers in awake mode | Supported through Mneme; `engine: pi` uses in-process tools and CLI engines receive pre-turn recall context only | | `execution.sandbox.mode` | Supported through OpenClaw runtime/container workspace behavior | Supported through `restrict_to_workspace` and container workspace behavior | Degraded; container/workspace isolation only, Pi itself is not a sandbox engine | | `subagents` | Degraded; routed sessions do not preserve full parent-owned semantics | Supported through PicoClaw subagent behavior | Degraded; grouped app agents do not preserve parent-owned subagent semantics | @@ -112,8 +112,9 @@ Support levels: | Anthropic `api_key` auth | Supported | Supported | Supported | | Anthropic `claude-code` auth | Supported | Supported | Supported through Pi's Anthropic OAuth auth store | | `custom` or `local` endpoint | Supported except subscription-import auth | Supported for compatible endpoint/auth pairs | Supported for `api_key` and `none` auth through generated Pi `models.json` | -| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Degraded | -| `schedule.kind: every` | Degraded | Degraded | Supported by the generated app scheduler | +| `schedule.kind: cron` | Degraded | Supported through `workspace/cron/jobs.json` | Supported through the durable native v2 scheduler | +| `schedule.kind: every` | Degraded | Degraded | Supported through the durable native v2 scheduler | +| `schedule.kind: disabled` | Supported, emits no wake registration | Supported, emits no wake registration | Supported, emits no wake registration | | `surfaces.moltnet` | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge | Supported through generated MoltnetNode bridge and Daimon control endpoint | | Discord, Telegram, WhatsApp, Slack | Supported with OpenClaw access-mode coverage | Partial: open and user allowlists; pairing and richer allowlists rejected | Rejected | | Webhook | Parsed, not lowered by active adapters in v0.1 | Parsed, not lowered by active adapters in v0.1 | Rejected | diff --git a/website/src/content/docs/spec/spec.md b/website/src/content/docs/spec/spec.md index 8cb03abd..72bc40c9 100644 --- a/website/src/content/docs/spec/spec.md +++ b/website/src/content/docs/spec/spec.md @@ -657,6 +657,7 @@ Rules: - `surfaces.slack.identity.user_id` is OPTIONAL. If present, it is the Slack user ID advertised in generated rosters where this agent is visible. - Surface `identity` fields are opt-in roster metadata. They do not provision accounts, validate provider-side membership, or cause Spawnfile to read runtime state. - `surfaces.moltnet` is a list of Moltnet attachments. Each attachment MUST declare `network` and at least one of `rooms` or `dms`. +- `surfaces.moltnet[].auth.token_id` is OPTIONAL and is valid only for a managed bearer network. The referenced actor token MUST use exactly `[attach, write]`, or `[attach, observe, write]` for a Daimon attachment, and MUST bind exactly that attachment's resolved Moltnet member ID. - Moltnet room and DM `wake` policy MAY be `all`, `mentions`, `thread_only`, or `never`. - Moltnet room and DM `reply` policy MAY be only `auto` or `never` in this alpha. `manual` is not part of the portable v0.1 contract. - Moltnet attachments are valid only when the agent participates in a team context whose `team.networks[]` declares the named network and rooms. @@ -891,7 +892,8 @@ Rules: - `every` schedules MUST declare a non-empty `every` interval. - `every` intervals use explicit duration strings such as `15m`, `2h`, or `1d`. - `timezone` defaults to `UTC` when omitted. -- `cron` and `every` schedules MAY declare `timezone` and `prompt`. +- `cron` schedules MUST declare `timezone` and MAY declare `prompt`. +- `every` schedules MAY declare `prompt` and MUST NOT declare `timezone`. - `disabled` schedules MUST NOT declare `cron`, `every`, `timezone`, or `prompt` fields. - A `disabled` schedule MUST not emit a spawn or wake registration. - Team manifests MUST NOT declare `schedule`. From 9baf1981dd63827f49e2e9e62b0febf48d45bf37 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 28 Aug 2026 19:42:59 +0200 Subject: [PATCH 16/34] ci: verify provenance-bound runtime artifacts --- .github/workflows/publish.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/test.yml | 32 ++++++++++++++++++++++++++++++++ package.json | 10 +++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e42219f1..d7843d5c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,6 +43,11 @@ jobs: - name: Audit runtime dependencies run: npm audit --omit=dev --audit-level=high + - name: Enable cross-architecture helper builds + uses: docker/setup-qemu-action@v3 + with: + platforms: amd64,arm64 + - name: Verify tag matches package version shell: bash run: | @@ -61,9 +66,33 @@ jobs: exit 1 fi + - name: Rebuild dual-architecture native helpers + run: npm run build:native + - name: Build run: npm run build + - name: Verify Git-free offline linux/amd64 source build + run: npm run test:source-provenance-docker + + - name: Check out Moltnet provenance fixture + uses: actions/checkout@v4 + with: + repository: noopolis/moltnet + path: moltnet-fixture + + - name: Verify Git-free offline linux/amd64 Moltnet build + env: + SPAWNFILE_TEST_MOLTNET_SOURCE: ${{ github.workspace }}/moltnet-fixture + run: npm run test:moltnet-source-provenance-docker + + - name: Remove Moltnet provenance fixture + if: always() + run: rm -rf -- moltnet-fixture + + - name: Verify native helper syscalls + run: node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.test.mjs + - name: Verify package contents run: npm run verify:package-closure diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 402a9c87..30d78f6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,12 +28,44 @@ jobs: - name: Audit runtime dependencies run: npm audit --omit=dev --audit-level=high + - name: Enable cross-architecture helper builds + uses: docker/setup-qemu-action@v3 + with: + platforms: amd64,arm64 + - name: Typecheck run: npm run typecheck + - name: Rebuild dual-architecture native helpers + run: npm run build:native + - name: Build run: npm run build + - name: Verify Git-free offline linux/amd64 source build + run: npm run test:source-provenance-docker + + - name: Check out Moltnet provenance fixture + uses: actions/checkout@v4 + with: + repository: noopolis/moltnet + path: moltnet-fixture + + - name: Verify Git-free offline linux/amd64 Moltnet build + env: + SPAWNFILE_TEST_MOLTNET_SOURCE: ${{ github.workspace }}/moltnet-fixture + run: npm run test:moltnet-source-provenance-docker + + - name: Remove Moltnet provenance fixture + if: always() + run: rm -rf -- moltnet-fixture + + - name: Verify native helper syscalls + run: node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.test.mjs + + - name: Verify named-volume product-state preseed + run: npm run test:product-state-volume + - name: Boundary tests run: npm run test:boundaries diff --git a/package.json b/package.json index 678bd48e..e9af15b0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "node": ">=22.19.0" }, "scripts": { - "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node ./src/evidenceExportHelper/copyAssets.mjs && node ./src/runtime/copyScaffoldAssets.mjs", + "compile:explicit-test-mcp": "node scripts/compile-explicit-test-mcp.mjs", + "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node ./src/evidenceExportHelper/copyAssets.mjs && node ./src/runtime/copyScaffoldAssets.mjs && node ./src/deployment/native/copyArtifacts.mjs", + "build:native": "node ./src/deployment/native/build.mjs", "clean": "rm -rf coverage dist", "coverage": "vitest run --coverage", "dev": "tsx src/cli/index.ts", @@ -33,6 +35,11 @@ "audit:generate": "tsx src/audit/auditCli.ts", "build:local-moltnet": "node ./scripts/build-local-moltnet.mjs", "build:local-daimon": "node ./scripts/build-local-daimon-runtime.mjs", + "bundle:source-provenance": "node ./scripts/create-source-provenance-bundle.mjs", + "prepare:linux-amd64-closure": "node ./scripts/create-linux-amd64-dependency-closure.mjs", + "prepare:linux-amd64-go-closure": "node ./scripts/create-linux-amd64-go-closure.mjs", + "test:source-provenance-docker": "node --test scripts/source-provenance-bundle.integration.test.mjs", + "test:moltnet-source-provenance-docker": "node --test scripts/moltnet-source-provenance.integration.test.mjs", "verify:package-closure": "node ./scripts/verify-package-closure.mjs", "test:e2e:docker-auth": "tsx src/e2e/cli.ts", "test:e2e:daimon-memory-recall": "tsx src/e2e/cli.ts daimon-memory-recall", @@ -50,6 +57,7 @@ "test:coverage-verdict": "node --import tsx -e \"import fs from 'node:fs'; const file='coverage/coverage-summary.json'; const fail=(message)=>{ console.error('COVERAGE VERDICT: '+message); process.exit(1); }; let summary; try { summary=JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { fail(error && error.code === 'ENOENT' ? 'summary file missing' : 'summary file unparseable'); } import('./vitest.config.ts').then(({default:config})=>{ const thresholds=config.test && config.test.coverage && config.test.coverage.thresholds; if (!thresholds || typeof thresholds !== 'object' || !summary || typeof summary !== 'object' || !summary.total || typeof summary.total !== 'object') fail('summary or threshold schema unrecognised'); const metricNames=['branches','functions','lines','statements']; const metrics=metricNames.filter((metric)=>Object.prototype.hasOwnProperty.call(thresholds, metric)); if (metrics.length === 0) fail('no threshold metrics configured'); let shortfall=false; for (const metric of metrics) { const threshold=thresholds[metric]; const pct=summary.total[metric] && summary.total[metric].pct; if (typeof threshold !== 'number' || !Number.isFinite(threshold) || typeof pct !== 'number' || !Number.isFinite(pct)) fail('missing or non-numeric value for '+metric); console.log('COVERAGE: '+metric+' '+pct+'% (threshold '+threshold+'%)'); if (pct < threshold) shortfall=true; } if (shortfall) { console.error('COVERAGE VERDICT: thresholds not met'); process.exit(2); } console.log('COVERAGE VERDICT: thresholds met'); }).catch((error)=>fail('could not load vitest config: '+error.message));\"", "test:node": "mkdir -p coverage || exit 1; rm -f coverage/node-test.tap || exit 1; node --import tsx --test --test-reporter=tap src/runtime/pi/appControlDeliveryMetadata.test.ts > coverage/node-test.tap 2>&1; node_status=$?; cat coverage/node-test.tap; cat_status=$?; npm run test:node-verdict; verdict_status=$?; if [ \"$verdict_status\" -eq 2 ]; then exit 2; fi; if [ \"$verdict_status\" -ne 0 ]; then exit 1; fi; if [ \"$cat_status\" -ne 0 ]; then echo 'NODE VERDICT: could not read TAP output' >&2; exit 1; fi; if [ \"$node_status\" -ne 0 ]; then echo \"NODE VERDICT: node:test runner exited $node_status despite a passing TAP summary\" >&2; exit 1; fi; exit 0", "test:node-verdict": "node -e \"const fs=require('fs'); const text=fs.readFileSync('coverage/node-test.tap', 'utf8'); const tests=text.match(/^# tests ([0-9]+)\\r?$/m); const suites=text.match(/^# suites ([0-9]+)\\r?$/m); const passed=text.match(/^# pass ([0-9]+)\\r?$/m); const failed=text.match(/^# fail ([0-9]+)\\r?$/m); const skipped=text.match(/^# skipped ([0-9]+)\\r?$/m); const todo=text.match(/^# todo ([0-9]+)\\r?$/m); const cancelled=text.match(/^# cancelled ([0-9]+)\\r?$/m); if (!tests || !suites || !passed || !failed || !skipped || !todo || !cancelled) { console.error('NODE VERDICT: TAP summary unrecognised'); process.exit(1); } const testCount=Number(tests[1]); const suiteCount=Number(suites[1]); const passCount=Number(passed[1]); const failCount=Number(failed[1]); const skippedCount=Number(skipped[1]); const todoCount=Number(todo[1]); const cancelledCount=Number(cancelled[1]); if (failCount > 0) { console.error('NODE VERDICT: test failures found (tests='+testCount+', failed='+failCount+')'); process.exit(2); } if (cancelledCount > 0) { console.error('NODE VERDICT: incomplete run (cancelled='+cancelledCount+')'); process.exit(1); } if (passCount + skippedCount + todoCount + cancelledCount !== testCount) { console.error('NODE VERDICT: TAP counts do not reconcile (tests='+testCount+', passed='+passCount+', skipped='+skippedCount+', todo='+todoCount+', cancelled='+cancelledCount+')'); process.exit(1); } if (testCount < 10) { console.error('NODE VERDICT: expected at least 10 node:test cases, found '+testCount+' — cases were removed or the file was gutted'); process.exit(1); } if (passCount < 10) { console.error('NODE VERDICT: expected at least 10 passing node:test cases, found '+passCount+' (skipped='+skippedCount+', todo='+todoCount+')'); process.exit(1); } if (suiteCount < 1) { console.error('NODE VERDICT: expected at least 1 node:test suite, found '+suiteCount); process.exit(1); } console.log('NODE VERDICT: tests passed (tests='+testCount+', suites='+suiteCount+', passed='+passCount+', skipped='+skippedCount+', todo='+todoCount+', failed=0)');\"", + "test:product-state-volume": "node scripts/product-state-volume-integration.test.mjs", "test:boundaries": "vitest run --coverage.enabled=false src/ownership/rootOwnershipBoundary.test.ts src/deployment/providerRuntimeBoundary.test.ts src/ownership/mnemePublicImportBoundary.test.ts src/target/containerBundleArchive.test.ts", "test:causal-conformance": "tsx src/ledger/causalConformanceCli.ts", "typecheck": "tsc --project tsconfig.json --noEmit", From d97d356924142d0d93c4821f5aa455522fede8b5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:10 +0200 Subject: [PATCH 17/34] fix(runtime): emit declared skills into the roots Moltnet installs its own skill into --- src/runtime/AGENTS.md | 15 +++- src/runtime/common.test.ts | 31 +++++++ src/runtime/common.ts | 44 ++++++++-- src/runtime/daimon/adapter.test.ts | 125 ++++++++++++++++++++++++++- src/runtime/daimon/adapter.ts | 74 ++++++++++++++-- src/runtime/openclaw/adapter.test.ts | 23 +++++ src/runtime/openclaw/adapter.ts | 5 +- src/runtime/pi/adapter.test.ts | 5 +- src/runtime/pi/adapter.ts | 3 +- src/runtime/picoclaw/adapter.test.ts | 19 ++++ src/runtime/picoclaw/adapter.ts | 5 +- 11 files changed, 326 insertions(+), 23 deletions(-) diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 61ee9a68..55a5cb45 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -10,11 +10,13 @@ src/runtime/ ├── scaffoldAssets.ts # Shared loader for runtime-owned init template assets ├── types.ts # Shared adapter contract types ├── common.ts # Shared lowering helpers used by adapters -├── mnemeMcp.ts # Shared Mneme MCP lowering used by MCP-capable runtimes +├── mnemeMcp.ts # Shared Mneme MCP lowering plus the durable memory mount authority ├── container.ts # Container install recipes (createRuntimeInstallRecipe) per bundled runtime ├── containerPackageOverrides.ts # Runtime install npm package override contract consumed by container.ts ├── localDaimonAuthority.ts # Exact non-production identity-file parser for loopback Daimon images ├── registry.ts # Bundled adapter registration and lookup +├── usageLedger.ts # Pure parser/aggregator for Daimon's per-turn usage ledger +├── usageLedgerRead.ts # Ledger read transport: `cat`s both generations through a caller-supplied exec and separates "absent" from "unreadable" ├── scheduleUtils.ts # Shared duration schedule helpers for runtime lowering ├── daimon/ # Public Daimon organization-host adapter ├── openclaw/ # OpenClaw adapter implementation @@ -25,6 +27,17 @@ src/runtime/ Adapter-specific behavior belongs in the runtime subfolders. That includes runtime-owned init scaffolds and scaffold markdown assets. `common.ts` should only hold logic that is truly shared across adapters. +`mnemeMcp.ts` also owns `resolveMnemeDurableMemoryMountPath`, the single +authority for "does this memory bank get a durable container volume, and at what +path". `src/compiler/memoryArtifacts.ts` calls it to emit the persistent mount +(which is also what puts the path into the Daimon UID entrypoint's writable state +roots, i.e. what chowns a fresh volume to the runtime uid), and `daimon/config.ts` +calls it to decide whether to emit an agent `memory` block at all. Keep those two +sides on this one function: a config that points an in-process Mneme runtime at a +path the container does not mount fails at its first write instead of degrading. + +`common.ts` owns where declared `workspace.skills` are emitted. `createSkillFiles` accepts either one root or a list of roots, and the roots are named constants there: `WORKSPACE_SKILL_BASE_DIRECTORY` (`workspace/skills`) for OpenClaw and PicoClaw, which read that directory with their own skill loaders, and `CLI_ENGINE_SKILL_BASE_DIRECTORIES` (`workspace/.agents/skills` and `workspace/.codex/skills`) for Daimon and Pi, whose skills are discovered by an external coding-agent CLI. Both CLI-engine roots are required and their files are byte-identical on purpose: `.codex/skills` is Codex's own discovery root and `.agents/skills` is the generic root grok, agy, and other file-reading engines use. This mirrors the Moltnet skill install exactly — `resolveMoltnetWorkspaceLayout` in `src/compiler/moltnetClientConfig.ts` runs `moltnet skill install --runtime codex` for these runtimes and Moltnet writes both roots — and it is the reason declared skills now reach an engine at all: a plain `workspace/skills/` root is read by no engine Daimon or Pi can host, so everything emitted there was invisible. + `common.ts` also owns the `NOOPOLIS_RUN_ID` container env constant (`NOOPOLIS_RUN_ID_ENV` / `resolveNoopolisRunId`); `container.ts` reads it via `createRuntimeContainerEnv` and stamps it into every generated `RuntimeInstallRecipe.env` so every authority container agrees on one run id for causal event envelopes (see `specs/CAUSAL.md`). Never read `run_id` or `principal_id` from model output here. `common.ts` additionally exports `ensureNoopolisRunId(env = process.env)`: the one place a run id is ever generated. It returns the host-provided value untouched when `resolveNoopolisRunId` already finds one, otherwise it generates a fresh id (`run-`) and stamps it onto `env` before returning it. It is exported through this folder's barrel (`index.ts`) so `src/compiler/runProject.ts` and `src/compiler/upProject.ts` can call it once, at the top of their `run`/`up` execution functions, before invoking `compileProject`/`buildProject` — never from inside `compileProject.ts`/`buildProject.ts` themselves, which must stay deterministic functions of whatever is already in the host env. `src/e2e/officeSim.ts` calls it too, since that harness builds a container directly rather than going through `runProject`/`upProject`. Without this, a host that never sets `NOOPOLIS_RUN_ID` (a bare `spawnfile up`, or an E2E harness) leaves `createRuntimeContainerEnv` with nothing to stamp, and moltnet's `causal.jsonl` capture ends up empty even though mneme/daimon still emit under their own `"unset-run"` fallback. diff --git a/src/runtime/common.test.ts b/src/runtime/common.test.ts index 2f36673c..2d657156 100644 --- a/src/runtime/common.test.ts +++ b/src/runtime/common.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { ResolvedAgentNode } from "../compiler/types.js"; import { + CLI_ENGINE_SKILL_BASE_DIRECTORIES, createAgentCapabilities, createDocumentFiles, createSkillFiles, @@ -64,6 +65,36 @@ describe("runtime common helpers", () => { ]); }); + it("fans one skill out across every requested discovery root", () => { + expect(createSkillFiles(["workspace/.agents/skills", "workspace/.codex/skills"], baseAgent.skills)).toEqual([ + { + content: "---\nname: web_search\ndescription: Search\n---\n", + path: "workspace/.agents/skills/web_search/SKILL.md" + }, + { + content: "---\nname: web_search\ndescription: Search\n---\n", + path: "workspace/.codex/skills/web_search/SKILL.md" + } + ]); + }); + + it("pins the CLI-engine skill roots to the roots Moltnet installs its own skill into", () => { + // resolveMoltnetWorkspaceLayout("daimon"|"pi") installs the Moltnet skill + // into exactly these two roots, and that skill demonstrably reaches the + // engine in a running container. Declared skills must land in the same + // places or no engine ever discovers them. + expect([...CLI_ENGINE_SKILL_BASE_DIRECTORIES]).toEqual([ + "workspace/.agents/skills", + "workspace/.codex/skills" + ]); + expect( + createSkillFiles(CLI_ENGINE_SKILL_BASE_DIRECTORIES, baseAgent.skills).map((file) => file.path) + ).toEqual([ + "workspace/.agents/skills/web_search/SKILL.md", + "workspace/.codex/skills/web_search/SKILL.md" + ]); + }); + it("creates capability entries for docs, skills, mcp, execution, and subagents", () => { const capabilities = createAgentCapabilities(baseAgent, { mcpOutcome: "degraded", diff --git a/src/runtime/common.ts b/src/runtime/common.ts index cd626a58..0bda0b64 100644 --- a/src/runtime/common.ts +++ b/src/runtime/common.ts @@ -92,14 +92,48 @@ export const createDocumentFiles = ( : `${baseDirectory}/extras/${document.role.replace(/^extras\./, "")}.md` })); +/** + * Skill roots for the runtimes whose declared skills are read by an external + * coding-agent CLI engine (`daimon` and `pi`). `.codex/skills` is Codex's own + * workspace discovery root; `.agents/skills` is the generic cross-engine root + * that grok, agy, and every other file-reading CLI engine use. Both entries + * are required and the two emitted files are byte-identical on purpose — this + * mirrors the Moltnet skill install, which is the working reference: Spawnfile + * runs `moltnet skill install --runtime codex` for these runtimes and Moltnet + * writes the same skill to both roots (`resolveMoltnetWorkspaceLayout` in + * `src/compiler/moltnetClientConfig.ts`, `installMoltnetSkill` in + * `moltnet/cmd/moltnet/skill.go`). A plain `workspace/skills/` root is read by + * no engine these two runtimes can host, so anything emitted there is invisible. + */ +export const CLI_ENGINE_SKILL_BASE_DIRECTORIES: readonly string[] = [ + "workspace/.agents/skills", + "workspace/.codex/skills" +]; + +/** + * Skill root for the runtimes that own their own skill loader and read the + * workspace `skills/` directory directly (`openclaw`, `picoclaw`). Moltnet + * installs its skill to the same place for those runtimes. + */ +export const WORKSPACE_SKILL_BASE_DIRECTORY = "workspace/skills"; + +/** + * Lowers declared skills into emitted `SKILL.md` files. `baseDirectory` + * accepts a list because a runtime can need the same skill under more than + * one discovery root (see `CLI_ENGINE_SKILL_BASE_DIRECTORIES`); the emission + * order is root-major so a generated workspace lists each root's skills + * together and stays deterministic. + */ export const createSkillFiles = ( - baseDirectory: string, + baseDirectory: string | readonly string[], skills: ResolvedSkill[] ): EmittedFile[] => - skills.map((skill) => ({ - content: skill.content, - path: `${baseDirectory}/${skill.name}/SKILL.md` - })); + (typeof baseDirectory === "string" ? [baseDirectory] : baseDirectory).flatMap((directory) => + skills.map((skill) => ({ + content: skill.content, + path: `${directory}/${skill.name}/SKILL.md` + })) + ); export const createAgentCapabilities = ( node: ResolvedAgentNode, diff --git a/src/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts index 676a47ab..fade4a55 100644 --- a/src/runtime/daimon/adapter.test.ts +++ b/src/runtime/daimon/adapter.test.ts @@ -4,6 +4,7 @@ import { createRootfsFiles } from "../../compiler/containerArtifactsRender.js"; import { renderEntrypoint } from "../../compiler/containerEntrypointRender.js"; import { resolveInstancePaths } from "../../compiler/containerTargetPlanResolution.js"; import type { RuntimeTargetPlan } from "../../compiler/containerArtifactsTypes.js"; +import { resolveMoltnetWorkspaceLayout } from "../../compiler/moltnetClientConfig.js"; import { createMoltnetNodeConfigContent } from "../../compiler/moltnetNodeConfig.js"; import { resolveRuntimeConfig } from "../../compiler/moltnetRuntimeConfig.js"; import type { CompilePlan } from "../../compiler/types.js"; @@ -52,6 +53,22 @@ const createPlan = async (): Promise => { }; describe("daimonAdapter", () => { + it("emits declared skills into the roots Moltnet installs its own skill into", async () => { + const compiled = await daimonAdapter.compileAgent(createDaimonNode("first", "First")); + // The Moltnet skill is the working reference: it is the one skill that + // demonstrably reaches the engine in a running Daimon container, and it + // is installed into these roots. Declared skills must use the same ones. + const moltnetSkillRoots = resolveMoltnetWorkspaceLayout("daimon", "First").skillPaths.map( + (skillPath) => skillPath.replace(/\/moltnet\/SKILL\.md$/, "") + ); + + expect(moltnetSkillRoots).toEqual(["workspace/.agents/skills", "workspace/.codex/skills"]); + expect( + compiled.files.map((file) => file.path).filter((filePath) => filePath.endsWith("/SKILL.md")) + ).toEqual(moltnetSkillRoots.map((root) => `${root}/note/SKILL.md`)); + expect(compiled.files.some((file) => file.path.startsWith("workspace/skills/"))).toBe(false); + }); + it("emits one strict organization host and no generated engine application", async () => { const plan = await createPlan(); const config = plan.targetFiles.find((file) => file.path === DAIMON_CONFIG_FILE); @@ -175,8 +192,15 @@ describe("daimonAdapter", () => { mountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", reason: "Daimon host Grok subscription credential realm" }, + { + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger" + }, { id: "daimon-agy-subscription-realm", + lifecycle: "exclusive-reattach", mountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", reason: "Daimon host AGY subscription realm" }, @@ -253,6 +277,12 @@ describe("daimonAdapter", () => { lifecycle: "exclusive-reattach", mountPath: "/var/lib/spawnfile/daimon/grok-subscription-realm", reason: "Daimon host Grok subscription credential realm" + }, + { + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger" } ]); expect(target.persistentMounts?.some((mount) => @@ -346,8 +376,59 @@ describe("daimonAdapter", () => { await expect(daimonAdapter.compileAgent({ ...base, mcpServers: [{ name: "missing", transport: "stdio", command: "/bin/tool" }] } as any)).rejects.toThrow(/tools allowlist/u); await expect(daimonAdapter.compileAgent({ ...base, mcpServers: [{ name: "relative", transport: "stdio", command: "tool", tools: ["act"] }] } as any)).rejects.toThrow(/absolute command/u); const agy = createDaimonNode("agy-tools", "agy-tools", "agy"); - await expect(daimonAdapter.compileAgent({ ...agy, mcpServers: [{ name: "tool", transport: "stdio", command: "/bin/tool", tools: ["act"] }] } as any)).rejects.toThrow(/AGY does not expose/u); - await expect(daimonAdapter.compileAgent({ ...agy, surfaces: { moltnet: [{ network: "news" }] } } as any)).rejects.toThrow(/AGY does not expose/u); + // The same two MCP validations apply to AGY as to every other engine. + await expect(daimonAdapter.compileAgent({ ...agy, mcpServers: [{ name: "missing", transport: "stdio", command: "/bin/tool" }] } as any)).rejects.toThrow(/tools allowlist/u); + await expect(daimonAdapter.compileAgent({ ...agy, mcpServers: [{ name: "relative", transport: "stdio", command: "tool", tools: ["act"] }] } as any)).rejects.toThrow(/absolute command/u); + }); + + it("lowers declared MCP and Moltnet for an AGY agent, like every other engine", async () => { + // Daimon used to refuse this outright ("Daimon AGY does not expose + // cognition tools"), which is what made an AGY agent unable to take part + // in a multi-agent organization at all. + const agy = createDaimonNode("agy-tools", "agy-tools", "agy"); + const node = { + ...agy, + mcpServers: [{ name: "tool", transport: "stdio", command: "/bin/tool", tools: ["act"], args: [], env: {} }], + surfaces: { moltnet: [{ network: "news", rooms: { lobby: {} } }] } + } as any; + const compiled = await daimonAdapter.compileAgent(node); + expect(compiled.capabilities).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: "mcp.tool", outcome: "supported" }), + expect.objectContaining({ key: "surfaces.moltnet", outcome: "supported" }) + ])); + const target = (await daimonAdapter.createContainerTargets!([ + { emittedFiles: [], id: "agent:agy", kind: "agent", slug: "agy", value: node } + ]))[0]!; + const config = JSON.parse(target.files.find((file) => file.path === "daimon-organization-runtime.json")!.content); + expect(config.agents[0].engine).toEqual({ kind: "agy" }); + expect(config.agents[0].mcp).toEqual([ + { name: "tool", transport: "stdio", args: [], env: {}, tools: ["act"], command: "/bin/tool" } + ]); + expect(config.agents[0].moltnet.networks).toEqual([{ id: "news", rooms: ["lobby"], dms: false }]); + }); + + it("keeps the AGY subscription realm and the usage ledger attached across deployments", async () => { + // Without `exclusive-reattach` the volume name folds in the run id + // (`createPersistentVolumeName`), so every `spawnfile up` hands the + // container an empty keyring and the operator has to redo the interactive + // OAuth enrolment. The usage ledger has to survive for the same reason: + // a run-scoped ledger makes cross-deployment accounting impossible, and an + // AGY-only organization needs it just as much as a Grok one. + const target = (await daimonAdapter.createContainerTargets!([ + { emittedFiles: [], id: "agent:agy", kind: "agent", slug: "agy", value: createDaimonNode("agy", "AGY", "agy") } + ]))[0]!; + expect(target.persistentMounts).toContainEqual({ + id: "daimon-agy-subscription-realm", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "Daimon host AGY subscription realm" + }); + 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("preserves non-workspace files and rejects invalid engines and oversized instructions", async () => { @@ -373,4 +454,44 @@ describe("daimonAdapter", () => { emittedFiles: [], id: "agent:oversized", kind: "agent", slug: "oversized", value: oversized }])).rejects.toThrow(/instructions/u); }); + + /** + * `restrict_to_workspace` is on this adapter's runtime-option allowlist and is + * read by nothing under `src/runtime/daimon/`. PicoClaw lowers an identically + * named option for real, which is what makes it look wired here. An author + * who declares it gets no error and none of the confinement they asked for, + * so the compile has to say so out loud. + */ + it("warns that a declared restrict_to_workspace is not enforced by the Daimon runtime", async () => { + const node = createDaimonNode("confined", "Confined"); + node.runtime.options.restrict_to_workspace = true; + + const compiled = await daimonAdapter.compileAgent(node); + + const messages = compiled.diagnostics.map((diagnostic) => diagnostic.message); + expect(messages).toContainEqual( + expect.stringContaining("Daimon organization runtime v1 does not enforce restrict_to_workspace") + ); + const warning = messages.find((message) => message.includes("does not enforce restrict_to_workspace"))!; + // The diagnostic has to name the agent, the actual behavior, and the way out. + expect(warning).toContain("Confined"); + expect(warning).toContain("can reach the whole container filesystem"); + expect(warning).toContain("picoclaw"); + // Warn, never reject: a project already declaring the option must keep compiling. + expect(compiled.diagnostics.every((diagnostic) => diagnostic.level !== "error")).toBe(true); + expect(daimonAdapter.validateRuntimeOptions?.({ engine: "codex", restrict_to_workspace: true })).toEqual([]); + }); + + it("stays silent about restrict_to_workspace when it is absent or explicitly false", async () => { + const undeclared = await daimonAdapter.compileAgent(createDaimonNode("plain", "Plain")); + const optedOut = createDaimonNode("open", "Open"); + optedOut.runtime.options.restrict_to_workspace = false; + + const compiled = await daimonAdapter.compileAgent(optedOut); + + for (const result of [undeclared, compiled]) { + expect(result.diagnostics.map((diagnostic) => diagnostic.message).join("\n")) + .not.toContain("does not enforce restrict_to_workspace"); + } + }); }); diff --git a/src/runtime/daimon/adapter.ts b/src/runtime/daimon/adapter.ts index 62c15221..620b1052 100644 --- a/src/runtime/daimon/adapter.ts +++ b/src/runtime/daimon/adapter.ts @@ -1,12 +1,21 @@ import type { EffectiveModelTarget, ResolvedAgentNode, ResolvedAgentSurfaces } from "../../compiler/types.js"; import type { CapabilityReport } from "../../report/index.js"; import { SpawnfileError } from "../../shared/index.js"; -import { createAgentCapabilities, createDiagnostic, createDocumentFiles, createSkillFiles } from "../common.js"; +import { + CLI_ENGINE_SKILL_BASE_DIRECTORIES, + createAgentCapabilities, + createDiagnostic, + createDocumentFiles, + createSkillFiles +} from "../common.js"; import { parseEveryScheduleMs } from "../scheduleUtils.js"; import type { AdapterCompileResult, RuntimeAdapter } from "../types.js"; import { createDaimonContainerTargets, + daimonMemoryCapabilityFor, + daimonMemorySelectionWarning, + daimonMemoryVectorRecallWarning, DAIMON_CONFIG_FILE, DAIMON_CONTROL_PORT, DAIMON_ENGINES, @@ -37,8 +46,50 @@ const assertDaimonModel = (target: EffectiveModelTarget): void => { ); }; +/** + * The compile-time warning for an agent that asked to be confined to its + * workspace on a runtime that does not confine anything. + * + * `restrict_to_workspace` is accepted by `validateRuntimeOptions` below (it is + * on the allowlist beside `engine`) and is then read by nothing under + * `src/runtime/daimon/`: the `noopolis.daimon.organization-runtime.v1` contract + * this adapter lowers into carries no workspace-confinement field, so the + * declaration reaches no runtime behavior at all. PicoClaw consumes an + * identically named option and really does lower it + * (`../picoclaw/adapter.ts`), which is exactly what makes the option look + * wired here. + * + * This warns rather than implementing confinement, because real workspace + * restriction for Daimon is a sandbox-profile change in daimon itself plus a + * widening of the digest-pinned organization runtime contract. It warns rather + * than rejecting, because an existing project that already declares the option + * must keep compiling. And it does not stay silent, because a security option + * that is accepted and ignored is a worse failure than one that is refused. + * + * Declaring `restrict_to_workspace: false` asks for nothing, so it says + * nothing. + */ +const daimonWorkspaceRestrictionWarning = (node: ResolvedAgentNode): string | undefined => { + const declared = node.runtime.options.restrict_to_workspace; + if (declared === undefined || declared === false) return undefined; + return `Daimon organization runtime v1 does not enforce restrict_to_workspace: agent ${node.name} declares ` + + "it, but no Daimon lowering reads the option and the organization runtime contract carries no workspace " + + "confinement field, so this agent's engine can reach the whole container filesystem. Move the agent to the " + + "picoclaw runtime, which lowers restrict_to_workspace into its agent defaults, or drop the option and treat " + + "the container boundary as this agent's only isolation."; +}; + +/** + * AGY is no longer excluded here. + * + * It used to be: an AGY agent that declared an MCP server or a Moltnet surface + * was rejected outright, because Daimon pinned AGY to `toolAccess: "none"`. + * Daimon now mounts AGY on the same per-wake MCP endpoint Codex and Grok get + * (`daimon/src/pi/cliMcpRegistration.ts`), so an AGY agent can take part in an + * organization, and the declaration this compiler lowers is one the runtime + * actually honours. The remaining validations are engine-independent and stay. + */ const unsupportedAgentFeatures = (node: ResolvedAgentNode): void => { - if (resolveDaimonEngine(node) === "agy" && (node.mcpServers.length > 0 || (node.surfaces?.moltnet?.length ?? 0) > 0)) throw new SpawnfileError("validation_error", "Daimon AGY does not expose cognition tools; use Codex or Grok for declared MCP or Moltnet actions"); for (const server of node.mcpServers) { if (!server.tools?.length) throw new SpawnfileError("validation_error", `Daimon MCP server ${server.name} requires an explicit tools allowlist`); if (server.transport === "stdio" && !server.command?.startsWith("/")) throw new SpawnfileError("validation_error", `Daimon stdio MCP server ${server.name} requires an absolute command`); @@ -112,22 +163,29 @@ export const daimonAdapter: RuntimeAdapter = { async compileAgent(node): Promise { unsupportedAgentFeatures(node); const scheduleCapability = await scheduleCapabilityFor(node); + const memorySelectionWarning = daimonMemorySelectionWarning(node); + const memoryVectorWarning = daimonMemoryVectorRecallWarning(node); + const workspaceRestrictionWarning = daimonWorkspaceRestrictionWarning(node); return { capabilities: createAgentCapabilities(node, { mcpOutcome: "supported", moltnetMessage: "Daimon exposes one scoped authenticated send tool during real cognition turns", moltnetOutcome: "supported", - memoryMessage: "Daimon organization runtime v1 does not lower Spawnfile memory declarations yet", - memoryOutcome: "degraded", + ...daimonMemoryCapabilityFor(node), scheduleMessage: scheduleCapability.message, scheduleOutcome: scheduleCapability.outcome }), - diagnostics: node.execution?.sandbox - ? [createDiagnostic("warn", "Daimon runtime isolation is enforced by the selected runtime image")] - : [], + diagnostics: [ + ...(node.execution?.sandbox + ? [createDiagnostic("warn", "Daimon runtime isolation is enforced by the selected runtime image")] + : []), + ...(memorySelectionWarning ? [createDiagnostic("warn", memorySelectionWarning)] : []), + ...(memoryVectorWarning ? [createDiagnostic("warn", memoryVectorWarning)] : []), + ...(workspaceRestrictionWarning ? [createDiagnostic("warn", workspaceRestrictionWarning)] : []) + ], files: [ ...createDocumentFiles("workspace", node.docs), - ...createSkillFiles("workspace/skills", node.skills) + ...createSkillFiles(CLI_ENGINE_SKILL_BASE_DIRECTORIES, node.skills) ] }; }, diff --git a/src/runtime/openclaw/adapter.test.ts b/src/runtime/openclaw/adapter.test.ts index 3896e1fe..1df764ff 100644 --- a/src/runtime/openclaw/adapter.test.ts +++ b/src/runtime/openclaw/adapter.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { ResolvedAgentNode } from "../../compiler/types.js"; +import { WORKSPACE_SKILL_BASE_DIRECTORY } from "../common.js"; + import { openClawAdapter } from "./adapter.js"; const createNode = (options: Record = {}): ResolvedAgentNode => ({ @@ -29,6 +31,27 @@ const createNode = (options: Record = {}): ResolvedAgentNode => }); describe("openClawAdapter", () => { + it("emits declared skills under the shared workspace skill root constant", async () => { + // OpenClaw reads `workspace/skills/` with its own loader. Deriving the + // path from common.ts's constant (rather than restating the literal here + // and in the adapter) is what keeps the adapter, PicoClaw, and + // src/runtime/AGENTS.md from drifting apart. + const result = await openClawAdapter.compileAgent({ + ...createNode(), + skills: [{ + content: "---\nname: web_search\ndescription: Search\n---\n", + name: "web_search", + ref: "./skills/web_search", + requiresMcp: [], + sourcePath: "/tmp/skills/web_search/SKILL.md" + }] + }); + + expect(result.files.map((file) => file.path)).toContain( + `${WORKSPACE_SKILL_BASE_DIRECTORY}/web_search/SKILL.md` + ); + }); + it("exposes container metadata for gateway boot", () => { expect(openClawAdapter.container).toEqual({ configFileName: "openclaw.json", diff --git a/src/runtime/openclaw/adapter.ts b/src/runtime/openclaw/adapter.ts index 9b85c569..7f6c59cc 100644 --- a/src/runtime/openclaw/adapter.ts +++ b/src/runtime/openclaw/adapter.ts @@ -12,7 +12,8 @@ import { createAgentCapabilities, createDiagnostic, createDocumentFiles, - createSkillFiles + createSkillFiles, + WORKSPACE_SKILL_BASE_DIRECTORY } from "../common.js"; import { SpawnfileError } from "../../shared/index.js"; import { applyOpenClawImportAuthConfig, resolveOpenClawImportAuthModes } from "./configAuth.js"; @@ -281,7 +282,7 @@ export const openClawAdapter: RuntimeAdapter = { ], files: [ ...createDocumentFiles("workspace", node.docs), - ...createSkillFiles("workspace/skills", node.skills), + ...createSkillFiles(WORKSPACE_SKILL_BASE_DIRECTORY, node.skills), ...createOpenClawStateFiles(), ...(cronStoreFile ? [cronStoreFile] : []), { diff --git a/src/runtime/pi/adapter.test.ts b/src/runtime/pi/adapter.test.ts index 23452c1c..7ed00b78 100644 --- a/src/runtime/pi/adapter.test.ts +++ b/src/runtime/pi/adapter.test.ts @@ -102,8 +102,9 @@ describe("piAdapter", () => { })); expect(compiled.files.map((file) => file.path).sort()).toEqual([ - "workspace/AGENTS.md", - "workspace/skills/note/SKILL.md" + "workspace/.agents/skills/note/SKILL.md", + "workspace/.codex/skills/note/SKILL.md", + "workspace/AGENTS.md" ]); expect(compiled.capabilities).toContainEqual({ key: "agent.schedule", diff --git a/src/runtime/pi/adapter.ts b/src/runtime/pi/adapter.ts index f54ec086..5e7b5179 100644 --- a/src/runtime/pi/adapter.ts +++ b/src/runtime/pi/adapter.ts @@ -3,6 +3,7 @@ import type { ResolvedAgentNode, ResolvedAgentSurfaces } from "../../compiler/ty import { readUtf8File, resolveProjectPath } from "../../filesystem/index.js"; import { SpawnfileError } from "../../shared/index.js"; import { + CLI_ENGINE_SKILL_BASE_DIRECTORIES, createAgentCapabilities, createDiagnostic, createDocumentFiles, @@ -244,7 +245,7 @@ export const piAdapter: RuntimeAdapter = { ], files: [ ...createDocumentFiles("workspace", node.docs), - ...createSkillFiles("workspace/skills", node.skills), + ...createSkillFiles(CLI_ENGINE_SKILL_BASE_DIRECTORIES, node.skills), ...(await createScriptedEngineFiles(node)) ] }; diff --git a/src/runtime/picoclaw/adapter.test.ts b/src/runtime/picoclaw/adapter.test.ts index 50b75658..4e6c663e 100644 --- a/src/runtime/picoclaw/adapter.test.ts +++ b/src/runtime/picoclaw/adapter.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { ResolvedAgentNode } from "../../compiler/types.js"; +import { WORKSPACE_SKILL_BASE_DIRECTORY } from "../common.js"; + import { picoClawAdapter } from "./adapter.js"; const node: ResolvedAgentNode = { @@ -29,6 +31,23 @@ const node: ResolvedAgentNode = { }; describe("picoClawAdapter", () => { + it("emits declared skills under the shared workspace skill root constant", async () => { + const result = await picoClawAdapter.compileAgent({ + ...node, + skills: [{ + content: "---\nname: web_search\ndescription: Search\n---\n", + name: "web_search", + ref: "./skills/web_search", + requiresMcp: [], + sourcePath: "/tmp/skills/web_search/SKILL.md" + }] + }); + + expect(result.files.map((file) => file.path)).toContain( + `${WORKSPACE_SKILL_BASE_DIRECTORY}/web_search/SKILL.md` + ); + }); + it("exposes container metadata for gateway boot", () => { expect(picoClawAdapter.container).toEqual({ configFileName: "config.json", diff --git a/src/runtime/picoclaw/adapter.ts b/src/runtime/picoclaw/adapter.ts index 98a5cb3f..e9678411 100644 --- a/src/runtime/picoclaw/adapter.ts +++ b/src/runtime/picoclaw/adapter.ts @@ -16,7 +16,8 @@ import { createAgentCapabilities, createDiagnostic, createDocumentFiles, - createSkillFiles + createSkillFiles, + WORKSPACE_SKILL_BASE_DIRECTORY } from "../common.js"; import { preparePicoClawRuntimeAuth } from "./runAuth.js"; import { createPicoClawAgentScaffold } from "./scaffold.js"; @@ -300,7 +301,7 @@ export const picoClawAdapter: RuntimeAdapter = { diagnostics: createScheduleDiagnostics(node), files: [ ...createDocumentFiles("workspace", node.docs), - ...createSkillFiles("workspace/skills", node.skills), + ...createSkillFiles(WORKSPACE_SKILL_BASE_DIRECTORY, node.skills), ...(cronStoreFile ? [cronStoreFile] : []), { content: buildPicoClawConfig(node), From 68e77fe6e12815b5964393a47a5c3967047c0963 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:18 +0200 Subject: [PATCH 18/34] fix(daimon): re-vendor the contract manifest and pin agents[].memory as consumed --- src/runtime/daimon/contract-manifest.json | 2 +- src/runtime/daimon/contract-manifest.sha256 | 2 +- src/runtime/daimon/contractManifest.test.ts | 25 ++++++++++++++-- src/runtime/daimon/contractManifest.ts | 32 +++++++++++++++++++-- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json index 677d1eac..69721f05 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"],"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"},"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"},"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[].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"}]}} diff --git a/src/runtime/daimon/contract-manifest.sha256 b/src/runtime/daimon/contract-manifest.sha256 index f7b01bde..3f07dbaf 100644 --- a/src/runtime/daimon/contract-manifest.sha256 +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -1 +1 @@ -sha256:65b21675dcc5a76395d345c4111a8abdeb36b43bcc5fd71292957a1e30fb5e5d +sha256:575788f5abb6e82cb6c163c76996d1efe8e06bb9a15c4a942617f6a20646bfed diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts index d4d1dabe..d84042f3 100644 --- a/src/runtime/daimon/contractManifest.test.ts +++ b/src/runtime/daimon/contractManifest.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; @@ -10,6 +11,7 @@ import { assertDaimonRuntimeHome, DAIMON_CONTRACT_MANIFEST_DIGEST_FILE, DAIMON_CONTRACT_MANIFEST_FILE, + DAIMON_CONTRACT_MANIFEST_SHA256, DAIMON_CONTRACT_MANIFEST_VERSION, DAIMON_GROK_ENGINE_BROKER, parseDaimonContractManifest, @@ -47,7 +49,7 @@ 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[].schedule.prompt", "agents[].mcp", "agents[].moltnet", "agents[].memory" ], engineCredentialMaterial: { codex: { destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" }, @@ -149,6 +151,25 @@ describe("Daimon contract manifest", () => { })).toThrow(/Grok engine broker/u); }); + /** + * The vendored `contract-manifest.json` / `contract-manifest.sha256` pair in + * this directory is a byte copy of what daimon's build emits, and + * `DAIMON_CONTRACT_MANIFEST_SHA256` is what the generated Dockerfile makes + * the runtime image attest against (src/runtime/container.ts). Nothing else + * checks that these three agree, so a daimon-side contract change that + * regenerates the manifest without re-vendoring it here produced a silent + * drift: the compiler pinned a digest no shipped runtime image could + * present. This is that missing check. + */ + it("verifies the vendored contract manifest against the pinned digest", async () => { + const vendoredRoot = path.dirname(fileURLToPath(import.meta.url)); + const vendored = await readVerifiedDaimonContractManifest(vendoredRoot); + expect(vendored.digest).toBe(DAIMON_CONTRACT_MANIFEST_SHA256); + const bytes = await readFile(path.join(vendoredRoot, DAIMON_CONTRACT_MANIFEST_FILE), "utf8"); + const raw = JSON.parse(bytes) as { consumedConfigFields: string[] }; + expect(raw.consumedConfigFields).toContain("agents[].memory"); + }); + it("rejects missing, malformed, noncanonical, and digest-mismatched packaged files", async () => { const missing = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-contract-missing-")); temporaryDirectories.push(missing); diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts index 76636529..8b5777a4 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:65b21675dcc5a76395d345c4111a8abdeb36b43bcc5fd71292957a1e30fb5e5d" as const; + "sha256:575788f5abb6e82cb6c163c76996d1efe8e06bb9a15c4a942617f6a20646bfed" 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"; @@ -52,6 +52,34 @@ export const DAIMON_GROK_ENGINE_BROKER = { arm64Sha256: "ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d" } } as const; +/** + * Where the Daimon broker writes its per-turn usage ledger, and what this + * compiler provisions. Deliberately kept out of `DAIMON_GROK_ENGINE_BROKER`: + * that object's canonical bytes are digest-pinned and attested against the + * runtime image at compile time, so a new key there would make every pinned + * image fail to attest. Mirrors `TURN_USAGE_LEDGER` in + * `daimon/src/runtime/turnUsageLedger.ts`. + */ +export const DAIMON_GROK_TURN_USAGE_LEDGER = { + version: "noopolis.daimon.turn-usage.v1", + directoryPath: "/var/lib/spawnfile/daimon/usage", + filePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl", + rotatedFilePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl.1", + directoryMode: 0o750, + fileMode: 0o640, + /** + * Mirrors `TURN_USAGE_ROTATE_BYTES` in `daimon/src/runtime/turnUsageLedger.ts` + * (Spawnfile must not import from `daimon/`, so this is the Spawnfile-side + * copy of the same agreed number). It is a LOWER bound on the size of a + * rotated generation, not an upper one: the broker rotates on the append + * *after* the file reaches this size, so `usage.jsonl.1` is always at least + * this large and the line that crossed the bound overshoots it. Anything + * sizing a read of one generation must therefore leave headroom above this + * number rather than matching it (see + * `DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES`). + */ + rotateBytes: 64 * 1024 * 1024 +} as const; export const DAIMON_AGY_SUBSCRIPTION_REALM = { directoryMode: 0o700, durableMountPath: "/var/lib/spawnfile/daimon/agy-subscription-realm", @@ -95,7 +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[].mcp", "agents[].moltnet" + "agents[].mcp", "agents[].moltnet", "agents[].memory" ] as const; const exactKeys = (value: Record, keys: readonly string[]): boolean => Object.keys(value).sort().join("\0") === [...keys].sort().join("\0"); From a719f89aa4381b56a8b4f520048302bd2ceaac76 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:18 +0200 Subject: [PATCH 19/34] feat(memory): lower Spawnfile memory declarations into the Daimon runtime config --- .../agents/localist/Spawnfile | 2 +- src/compiler/memoryArtifacts.test.ts | 225 ++++++++++- src/compiler/memoryArtifacts.ts | 172 ++++++-- src/runtime/daimon/AGENTS.md | 51 +++ src/runtime/daimon/config.test.ts | 378 ++++++++++++++++++ src/runtime/daimon/config.ts | 49 ++- src/runtime/daimon/memory.ts | 159 ++++++++ src/runtime/index.ts | 2 + src/runtime/mnemeMcp.ts | 34 ++ 9 files changed, 1008 insertions(+), 64 deletions(-) create mode 100644 src/runtime/daimon/config.test.ts create mode 100644 src/runtime/daimon/memory.ts diff --git a/examples/mixed-runtime-org/agents/localist/Spawnfile b/examples/mixed-runtime-org/agents/localist/Spawnfile index 561d09e0..e53407d8 100644 --- a/examples/mixed-runtime-org/agents/localist/Spawnfile +++ b/examples/mixed-runtime-org/agents/localist/Spawnfile @@ -38,7 +38,7 @@ memory: - id: localist-floor store: kind: sqlite - path: /var/lib/spawnfile/memory/mixed-floor/localist-floor.sqlite + path: /var/lib/spawnfile/memory/mixed-localist/localist-floor.sqlite index: lexical: enabled: true diff --git a/src/compiler/memoryArtifacts.test.ts b/src/compiler/memoryArtifacts.test.ts index 6153f9f2..971bf3db 100644 --- a/src/compiler/memoryArtifacts.test.ts +++ b/src/compiler/memoryArtifacts.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import type { CompilePlan, ResolvedMemoryAccess, ResolvedMemoryBank } from "./types.js"; import { createMemoryArtifactBundle } from "./memoryArtifacts.js"; -import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; +import { NOOPOLIS_RUN_ID_ENV } from "../runtime/common.js"; +import { createExclusiveReattachVolumeName } from "../shared/index.js"; const baseMemoryIndex = { graph: { enabled: false }, @@ -169,6 +170,134 @@ describe("createMemoryArtifactBundle", () => { }); }); + it("reports direct transport for a daimon agent with a sqlite bank", () => { + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "shared", { + kind: "sqlite", + persistence: { mode: "durable" }, + path: "/var/lib/spawnfile/memory/daimon/shared/memory.sqlite" + }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "direct" + }); + }); + + it("reports direct transport for a daimon agent with a json bank", () => { + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "shared", { + kind: "json", + persistence: { mode: "durable" }, + path: "/var/lib/spawnfile/memory/daimon/shared/memory.jsonl" + }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "direct" + }); + }); + + it("reports unsupported transport for a daimon agent with a postgres bank", () => { + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "remote", { + kind: "postgres", + dsn_secret: "MEMORY_DSN" + }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "unsupported" + }); + }); + + it("reports degraded transport for a pi agent with a memory-kind bank", () => { + const piNode = createPlanNode("agent:pi-agent", "pi-agent", "pi", "/tmp/pi/Spawnfile"); + const bank = createBank("/tmp/pi/Spawnfile", "volatile", { kind: "memory" }); + + const bundle = createMemoryArtifactBundle( + createPlan([piNode], [ + { agentSource: piNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:pi-agent": "degraded" + }); + }); + + it("reports degraded transport for a daimon agent with a memory-kind bank", () => { + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "volatile", { kind: "memory" }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "degraded" + }); + }); + + it("reports degraded transport for an ephemeral sqlite bank, matching the mounts it emits", () => { + // An ephemeral file-backed bank gets no durable volume and no daimon + // memory block, so reporting `direct` would make the report disagree + // with what was actually emitted. + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const piNode = createPlanNode("agent:pi-agent", "pi-agent", "pi", "/tmp/pi/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "scratch", { + kind: "sqlite", + persistence: { mode: "ephemeral" }, + path: "/tmp/scratch.sqlite" + }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode, piNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank }, + { agentSource: piNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.mounts).toHaveLength(0); + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "degraded", + "agent:pi-agent": "degraded" + }); + }); + + it("reports degraded transport for a json bank with no resolvable mount path", () => { + const daimonNode = createPlanNode("agent:daimon-agent", "daimon-agent", "daimon", "/tmp/daimon/Spawnfile"); + const bank = createBank("/tmp/daimon/Spawnfile", "pathless", { kind: "json" }); + + const bundle = createMemoryArtifactBundle( + createPlan([daimonNode], [ + { agentSource: daimonNode.value.source, declaringKind: "agent", source: bank.source, bank } + ]) + ); + + expect(bundle.mounts).toHaveLength(0); + expect(bundle.memories[0]?.transport_by_node_id).toEqual({ + "agent:daimon-agent": "degraded" + }); + }); + it("adds durable sqlite mounts and mount IDs", () => { const declaring = createPlanNode("agent:assistant", "assistant", "pi", "/tmp/pi/Spawnfile"); const bank = createBank("/tmp/pi/Spawnfile", "journal", { @@ -189,8 +318,11 @@ describe("createMemoryArtifactBundle", () => { expect(bundle.mounts).toEqual([ { id: "memory-var-lib-spawnfile-persist-journal", + lifecycle: "exclusive-reattach", mount_path: "/var/lib/spawnfile/persist/journal", reason: "durable memory stores under /var/lib/spawnfile/persist/journal", + // An author-declared persistence.name is a request for a host-stable + // volume identity, so it is honored verbatim rather than derived. volume_name: "journal-store" } ]); @@ -200,7 +332,13 @@ describe("createMemoryArtifactBundle", () => { expect(bundle.memories[0]?.store.persistent_mount_id).toBe("memory-var-lib-spawnfile-persist-journal"); }); - it("shares one durable mount for multiple memory files in the same directory", () => { + /** + * Mneme keys a store by its runtime home directory and discards the declared + * filename, so two banks declaring different files in one directory are one + * physical store with two writers. This used to compile silently, sharing the + * mount between them. + */ + it("rejects two distinct banks that resolve to the same durable directory", () => { const declaring = createPlanNode("agent:assistant", "assistant", "pi", "/tmp/pi/Spawnfile"); const first = createBank("/tmp/pi/Spawnfile", "journal", { kind: "sqlite", @@ -212,32 +350,81 @@ describe("createMemoryArtifactBundle", () => { persistence: { mode: "durable" }, path: "/var/lib/spawnfile/memory/assistant/notes.jsonl" }); - const bundle = createMemoryArtifactBundle( + + expect(() => createMemoryArtifactBundle( createPlan([declaring], [ { agentSource: declaring.value.source, declaringKind: "agent", source: first.source, bank: first }, { agentSource: declaring.value.source, declaringKind: "agent", source: second.source, bank: second } ]) + )).toThrow(/both resolve to the durable memory directory \/var\/lib\/spawnfile\/memory\/assistant/u); + }); + + /** + * The legitimate shape the guard must NOT reject: one bank declared twice, in + * an org scope and again in a nested team scope, so agents on both sides of + * the team boundary can reach it. `examples/daimon-org` ships exactly this. + */ + it("shares one durable mount when the same bank is declared in two scopes", () => { + const orgAgent = createPlanNode("agent:mapper", "mapper", "pi", "/tmp/pi/Spawnfile"); + const teamAgent = createPlanNode("agent:reviewer", "reviewer", "pi", "/tmp/pi/team/Spawnfile"); + const store = { + kind: "json" as const, + persistence: { mode: "durable" as const }, + path: "/var/lib/spawnfile/memory/org/shared-recall.jsonl" + }; + const orgBank = createBank("/tmp/pi/Spawnfile", "shared-recall", store); + const teamBank = createBank("/tmp/pi/team/Spawnfile", "shared-recall", store); + + const bundle = createMemoryArtifactBundle( + createPlan([orgAgent, teamAgent], [ + { agentSource: orgAgent.value.source, declaringKind: "team", source: orgBank.source, bank: orgBank }, + { agentSource: teamAgent.value.source, declaringKind: "team", source: teamBank.source, bank: teamBank } + ]) ); expect(bundle.mounts).toHaveLength(1); - expect(bundle.mounts[0]).toEqual({ - id: "memory-var-lib-spawnfile-memory-assistant", - mount_path: "/var/lib/spawnfile/memory/assistant", - reason: "durable memory stores under /var/lib/spawnfile/memory/assistant", - // Now project-scoped via createPersistentVolumeName (plan.root, here - // "/tmp/pi/Spawnfile") rather than a bare path slug, so two different - // projects sharing this mount-path convention no longer collide on - // the same host docker volume. No NOOPOLIS_RUN_ID is set in this test - // process env, so no run segment is folded in. - volume_name: createPersistentVolumeName( - "/tmp/pi/Spawnfile", - "memory-var-lib-spawnfile-memory-assistant" - ) - }); expect(bundle.memories.map((entry) => entry.store.persistent_mount_id)).toEqual([ - "memory-var-lib-spawnfile-memory-assistant", - "memory-var-lib-spawnfile-memory-assistant" + "memory-var-lib-spawnfile-memory-org", + "memory-var-lib-spawnfile-memory-org" + ]); + }); + + /** + * A durable memory volume must survive a redeploy. Run-scoping it (the old + * behavior) meant every `spawnfile up` mounted a fresh empty volume, so the + * organization redeployed tomorrow remembered nothing. + */ + it("names durable memory volumes by deployment lineage, not by run id", () => { + const declaring = createPlanNode("agent:assistant", "assistant", "pi", "/tmp/pi/Spawnfile"); + const bank = createBank("/tmp/pi/Spawnfile", "journal", { + kind: "sqlite", + persistence: { mode: "durable" }, + path: "/var/lib/spawnfile/memory/assistant/journal.sqlite" + }); + const access = [ + { agentSource: declaring.value.source, declaringKind: "agent" as const, source: bank.source, bank } + ]; + const mountId = "memory-var-lib-spawnfile-memory-assistant"; + + const withoutRunId = createMemoryArtifactBundle(createPlan([declaring], access), "production"); + process.env[NOOPOLIS_RUN_ID_ENV] = "run-aaaaaaaa"; + const withRunId = createMemoryArtifactBundle(createPlan([declaring], access), "production"); + delete process.env[NOOPOLIS_RUN_ID_ENV]; + + expect(withoutRunId.mounts).toEqual([ + { + id: mountId, + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/memory/assistant", + reason: "durable memory stores under /var/lib/spawnfile/memory/assistant", + volume_name: createExclusiveReattachVolumeName("/tmp/pi/Spawnfile\u0000production", mountId) + } ]); + expect(withRunId.mounts).toEqual(withoutRunId.mounts); + // A different deployment lineage is still a different volume. + expect( + createMemoryArtifactBundle(createPlan([declaring], access), "staging").mounts[0]?.volume_name + ).not.toBe(withoutRunId.mounts[0]?.volume_name); }); it("skips durable mounts for ephemeral sqlite/json stores and non-file stores", () => { diff --git a/src/compiler/memoryArtifacts.ts b/src/compiler/memoryArtifacts.ts index f4f5eadc..06fdc912 100644 --- a/src/compiler/memoryArtifacts.ts +++ b/src/compiler/memoryArtifacts.ts @@ -1,9 +1,7 @@ -import path from "node:path/posix"; - -import { resolveNoopolisRunId } from "../runtime/index.js"; +import { resolveMnemeDurableMemoryMountPath } from "../runtime/mnemeMcp.js"; +import { createExclusiveReattachVolumeName, SpawnfileError } from "../shared/index.js"; import { slugify } from "./helpers.js"; -import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { CompilePlan, ResolvedMemoryAccess, @@ -33,9 +31,6 @@ type MemoryArtifactSummary = { transport_by_node_id: Record; }; -const normalizePosixPath = (value: string): string => - path.normalize(value).replace(/\/+$/u, "") || "/"; - const safeSlug = (value: string): string => slugify(value).trim() || "memory"; @@ -52,7 +47,33 @@ const transportFromRuntime = ( switch (runtimeName) { case "pi": case "daimon": - return "direct"; + // Mneme runs in-process for these runtimes (no MCP subprocess), but + // whether that in-process runtime is reachable -- and whether it + // persists anything -- depends on the store: + // - sqlite/json WITH a durable mount: a real runtime home backed by + // a persistent volume this same module emits. + // - sqlite/json WITHOUT one (persistence.mode "ephemeral", or no + // resolvable path at all): no volume is mounted and Daimon emits no + // memory block, so recall lasts at most the container's lifetime -- + // exactly the "memory" kind's situation, and reported the same way. + // - memory: Mneme still runs in-process against a synthesized runtime + // home path, but no persistent volume is emitted for this kind. + // - postgres: no runtime home path at all; the in-process runtime + // gets no memory whatsoever. + // + // The durable/ephemeral half of that decision is NOT re-derived here: + // it comes from resolveMnemeDurableMemoryMountPath, the same authority + // that decides the mount below, so the report cannot disagree with what + // was actually emitted. + switch (bank.store.kind) { + case "sqlite": + case "json": + return resolveMnemeDurableMemoryMountPath(bank) === null ? "degraded" : "direct"; + case "memory": + return "degraded"; + default: + return "unsupported"; + } case "picoclaw": case "openclaw": return isFileBackedMemoryStore(bank) ? "mcp" : "degraded_mcp"; @@ -61,25 +82,10 @@ const transportFromRuntime = ( } }; -const memoryMountPath = ( - bank: ResolvedMemoryAccess["bank"] -): string | null => { - if (bank.store.kind !== "sqlite" && bank.store.kind !== "json") { - return null; - } - - if (bank.store.persistence?.mode === "ephemeral") { - return null; - } - - if (bank.store.persistence?.mount) { - return normalizePosixPath(bank.store.persistence.mount); - } - - return bank.store.path - ? path.dirname(normalizePosixPath(bank.store.path)) - : null; -}; +// The mount decision itself lives in runtime/mnemeMcp.ts so that runtime config +// emitters (which must only point an in-process Mneme runtime at a path this +// module actually mounts) and this module cannot drift apart. +const memoryMountPath = resolveMnemeDurableMemoryMountPath; const summarizeMemoryStore = (bank: ResolvedMemoryAccess["bank"], mountId?: string) => { const persistenceMode = bank.store.kind === "sqlite" || bank.store.kind === "json" @@ -95,19 +101,98 @@ const summarizeMemoryStore = (bank: ResolvedMemoryAccess["bank"], mountId?: stri }; }; +interface DurableMemoryClaimant { + bankId: string; + identity: string; + source: string; +} + +/** + * The identity of the PHYSICAL store a declared bank resolves to. + * + * Mneme addresses a store by its runtime home directory and then discards the + * declared filename: `JsonlMemoryStore` always writes `/memory/events.jsonl` + * and `SQLiteMemoryIndex` always opens `/memory/.db` + * (mneme/src/store/store.ts, mneme/src/store/sqliteIndex.ts). Spawnfile resolves + * that home as `dirname(store.path)` unless `persistence.mount` overrides it, so + * `/data/mem/a.jsonl` and `/data/mem/b.jsonl` are ONE physical store wearing two + * declared names — two concurrent MemoryRuntimes appending to one file and + * opening one SQLite database. + * + * Two declarations may therefore share a durable directory only when they are + * the same bank said twice (the legitimate case: one bank declared in an org + * scope and again in a nested team scope so both sides can access it). Anything + * that differs — a different id, a different declared file, a different index + * intent — means the author believes they have two stores and the runtime will + * silently give them one. + */ +const physicalStoreIdentity = (bank: ResolvedMemoryBank): string => JSON.stringify({ + consolidation: bank.consolidation, + id: bank.id, + index: bank.index, + retention: bank.retention, + store: { + kind: bank.store.kind, + mount: bank.store.persistence?.mount ?? null, + name: bank.store.persistence?.name ?? null, + path: bank.store.path ?? null + } +}); + +const collidingBankMessage = ( + mountPath: string, + prior: DurableMemoryClaimant, + next: DurableMemoryClaimant +): string => + `Memory banks ${prior.bankId} (declared in ${prior.source}) and ${next.bankId} ` + + `(declared in ${next.source}) both resolve to the durable memory directory ${mountPath}, ` + + "but do not declare the same store. Mneme keys a store by that directory and ignores the " + + "declared filename, so these two banks would silently become one physical store with two " + + "runtimes writing it. Give each bank its own directory (a distinct store.path parent or " + + "persistence.mount), or declare them identically if one shared bank is what you meant."; + +/** + * The docker volume name for a durable memory directory. + * + * Deliberately NOT run-scoped. `createPersistentVolumeName` folds NOOPOLIS_RUN_ID + * into the name, and `ensureNoopolisRunId` mints a fresh id on every `spawnfile + * run`/`up`, so a run-scoped memory volume means the organization you redeploy + * tomorrow starts with an empty memory bank. There is no working escape hatch + * today either: `spawnfile product-state clone` refuses SQLite paths, and reusing + * yesterday's NOOPOLIS_RUN_ID to reproduce the volume name would collapse two + * distinct causal runs onto one run_id in the ledger (specs/CAUSAL.md). + * + * `exclusive-reattach` is the existing lifecycle for exactly this: a host-stable + * name derived from the project root plus the deployment lineage, with a + * daemon-side reservation that refuses to start when another live container + * already holds the volume (src/compiler/runProjectDockerReservation.ts). That + * mutual exclusion is a requirement here rather than a cost — Mneme's append-only + * JSONL plus its SQLite index are single-writer — and it is why an organization + * with durable memory cannot use the concurrent blue/green canary workflow and + * must stop-and-reattach instead (specs/CONTAINERS.md). + * + * An author-declared `persistence.name` is honored verbatim: naming a volume is + * precisely a request for a host-stable identity. + */ +const durableMemoryVolumeName = ( + planRoot: string, + mountId: string, + declaredName: string | undefined, + deploymentLineage: string +): string => + declaredName?.trim() + || createExclusiveReattachVolumeName(`${planRoot}\0${deploymentLineage}`, mountId); + export interface MemoryArtifactBundle { mountPathMemoryMap: Map; mounts: ContainerPersistentMountReport[]; memories: MemoryArtifactSummary[]; } -export const createMemoryArtifactBundle = (plan: CompilePlan): MemoryArtifactBundle => { - // Run-scoping key for every derived volume name below (see - // createPersistentVolumeName's doc comment in moltnetArtifactPaths.ts): - // present whenever this compile was driven by `spawnfile run`/`up` - // (which always call ensureNoopolisRunId() first), absent for a bare - // `spawnfile compile`/`spawnfile build`. - const runId = resolveNoopolisRunId(process.env); +export const createMemoryArtifactBundle = ( + plan: CompilePlan, + deploymentLineage = "compile" +): MemoryArtifactBundle => { const sourceToNode = new Map( plan.nodes.map((node) => [node.value.source, node] as const) ); @@ -126,6 +211,7 @@ export const createMemoryArtifactBundle = (plan: CompilePlan): MemoryArtifactBun const mounts: ContainerPersistentMountReport[] = []; const mountPathMemoryMap = new Map(); const mountByPath = new Map(); + const claimantByPath = new Map(); const memories = [...groupedMemory.entries()] .sort(([left], [right]) => left.localeCompare(right)) @@ -154,17 +240,20 @@ export const createMemoryArtifactBundle = (plan: CompilePlan): MemoryArtifactBun if (mountPath !== null) { const mountId = createMountId(mountPath); - // createPersistentVolumeName also folds in plan.root (previously - // missing here entirely), so two different projects that happen to - // share a mount-path convention no longer derive the same volume - // name — the same isolation moltnet's own persistent mounts already - // had before this change. - const volumeName = createPersistentVolumeName( + const volumeName = durableMemoryVolumeName( plan.root, mountId, entry.bank.store.persistence?.name, - runId + deploymentLineage ); + const identity = physicalStoreIdentity(entry.bank); + const claimant = { bankId: entry.bank.id, identity, source: entry.source }; + const priorClaimant = claimantByPath.get(mountPath); + if (priorClaimant && priorClaimant.identity !== identity) { + throw new SpawnfileError("validation_error", collidingBankMessage(mountPath, priorClaimant, claimant)); + } + claimantByPath.set(mountPath, claimant); + const existingMount = mountByPath.get(mountPath); if (existingMount && existingMount.volume_name !== volumeName) { throw new Error( @@ -174,6 +263,7 @@ export const createMemoryArtifactBundle = (plan: CompilePlan): MemoryArtifactBun const mount = existingMount ?? { id: mountId, + lifecycle: "exclusive-reattach" as const, mount_path: mountPath, reason: `durable memory stores under ${mountPath}`, volume_name: volumeName diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 6b00897d..3c83e952 100644 --- a/src/runtime/daimon/AGENTS.md +++ b/src/runtime/daimon/AGENTS.md @@ -16,6 +16,21 @@ The consumed Daimon manifest declares stable AGY and Grok host-realm volumes plus their opaque bootstrap slots. This adapter renders those resources but never starts a provider CLI, D-Bus, or a turn. +Both host-realm volumes and the per-turn usage ledger carry +`lifecycle: "exclusive-reattach"`. That is not decoration: without it the +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. + +All three engines lower declared MCP servers and Moltnet surfaces. AGY was +excluded until Daimon learned to register its per-wake MCP endpoint through +`agy mcp add`; the compiler-side MCP validations (explicit tools allowlist, +absolute stdio command) are engine-independent and still apply. + The consumed manifest also pins the native Grok broker source/x64/arm64 digests, fixed root/org/broker/worker identities, root-only registrations, and loopback-only provider/MCP endpoints. Container provisioning must match @@ -24,3 +39,39 @@ that authority exactly and must not publish either broker port. Codex keeps an isolated per-agent credential home. Grok keeps isolated per-agent non-auth state but one durable rotating subscription credential realm; never fan out Grok refresh authority across writable homes. + +Memory lowering is `memory.ts`'s `resolveDaimonAgentMemory` (split out of +`config.ts` to stay inside the 400-line source bound, and re-exported from +`config.ts` so existing importers are unaffected). Daimon accepts one +`memory` block per agent (`{ runtimeHomePath, source?, tokenBudget? }`, camelCase, +no unknown keys), so this adapter picks a single declared bank deterministically +and emits it only when `resolveMnemeDurableMemoryMountPath` (`../mnemeMcp.ts`) +says the compiler mounts a durable volume for it. That predicate is shared with +`src/compiler/memoryArtifacts.ts` on purpose: a runtime home with no persistent +mount is absent or root-owned inside the container, so an in-process Mneme +runtime pointed at one fails its first write. Everything else -- postgres, +in-process `memory` stores, ephemeral persistence, or a second declared bank -- +stays a `degraded` memory capability with no emitted block. + +That block carries no embedding configuration, and Daimon's CLI harness never +sets `memory.embeddingProvider`, so vector recall does not exist on this +runtime. A bank declaring `index.vector.enabled` still compiles and still +recalls -- lexically. `daimonMemoryVectorRecallWarning` says so as a compile +diagnostic and degrades the memory capability, because the alternative is a +declaration that is accepted verbatim and quietly means something else. +Forwarding the configuration instead would mean widening the digest-pinned +organization runtime contract and building an embedding provider in daimon. + +`restrict_to_workspace` has the same shape of problem and the same answer. +`validateRuntimeOptions` allowlists it beside `engine`, and nothing under this +folder reads it: the organization runtime contract carries no +workspace-confinement field. PicoClaw lowers an identically named option for +real, which is what makes it look wired here. +`daimonWorkspaceRestrictionWarning` (in `adapter.ts`, next to the other compile +diagnostics) names the agent, states that the option reaches no runtime +behavior, and points at the picoclaw runtime as the way to actually get it. +Warn, never reject -- a project already declaring the option has to keep +compiling -- and never silently drop it, because a security option that is +accepted and ignored is worse than one that is refused. Implementing real +confinement is a daimon sandbox-profile change plus a contract widening, not an +adapter change. diff --git a/src/runtime/daimon/config.test.ts b/src/runtime/daimon/config.test.ts new file mode 100644 index 00000000..e9436ade --- /dev/null +++ b/src/runtime/daimon/config.test.ts @@ -0,0 +1,378 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveDaimonUidEntrypointOwnershipPlan } from "../../compiler/containerDaimonUidEntrypointRender.js"; +import { createMemoryArtifactBundle } from "../../compiler/memoryArtifacts.js"; +import { resolveInstancePaths } from "../../compiler/containerTargetPlanResolution.js"; +import type { RuntimeTargetPlan } from "../../compiler/containerArtifactsTypes.js"; +import type { + CompilePlan, + ResolvedAgentNode, + ResolvedMemoryAccess, + ResolvedMemoryBank +} from "../../compiler/types.js"; +import { createPiTestNode } from "../pi/testHelpers.js"; + +import { daimonAdapter } from "./adapter.js"; +import { + createDaimonContainerTargets, + DAIMON_CONFIG_FILE, + DAIMON_INSTANCE_STATE_ROOT, + DAIMON_ORGANIZATION_TARGET_ID +} from "./config.js"; + +const AGENT_SOURCE = "/tmp/agent/keeper/Spawnfile"; +const TEAM_SOURCE = "/tmp/team/Spawnfile"; + +const createBank = ( + id: string, + store: ResolvedMemoryBank["store"] +): ResolvedMemoryBank => ({ + consolidation: { mode: "disabled" }, + declaredBy: "team", + declaredName: "lab", + id, + index: { + graph: { enabled: false }, + lexical: { enabled: true }, + rerank: { enabled: false }, + vector: { enabled: false } + }, + retention: { forgetting: "manual" }, + source: TEAM_SOURCE, + store +}); + +const createAccess = (bank: ResolvedMemoryBank): ResolvedMemoryAccess => ({ + agentSource: AGENT_SOURCE, + bank, + declaringKind: "team", + slotId: "keeper", + source: TEAM_SOURCE +}); + +const createDaimonNode = ( + overrides: Partial = {} +): ResolvedAgentNode => + createPiTestNode({ + name: "Keeper", + runtime: { name: "daimon", options: { engine: "codex" } }, + source: AGENT_SOURCE, + ...overrides + }); + +const emitConfig = async (node: ResolvedAgentNode): Promise<{ + agents: Array>; +}> => { + const compiled = await daimonAdapter.compileAgent(node); + const target = (await createDaimonContainerTargets([ + { + emittedFiles: compiled.files, + id: "agent:keeper", + kind: "agent", + slug: "keeper", + value: node + } + ]))[0]!; + return JSON.parse( + target.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content + ); +}; + +const memoryCapabilities = async (node: ResolvedAgentNode) => + (await daimonAdapter.compileAgent(node)).capabilities.filter((capability) => + capability.key === "memory" || capability.key.startsWith("memory.")); + +const createPlan = (node: ResolvedAgentNode, access: ResolvedMemoryAccess): CompilePlan => ({ + edges: [], + memoryAccess: [access], + nodes: [ + { id: "agent:keeper", kind: "agent", runtimeName: "daimon", slug: "keeper", value: node }, + { + id: "team:lab", + kind: "team", + runtimeName: null, + slug: "lab", + value: { + description: "", + docs: [], + kind: "team" as const, + members: [], + name: "lab", + policyMode: null, + policyOnDegrade: null, + source: TEAM_SOURCE + } + } + ] as unknown as CompilePlan["nodes"], + root: AGENT_SOURCE, + runtimes: { daimon: { nodeIds: ["agent:keeper"] } } +}); + +const createRuntimeTargetPlan = (): RuntimeTargetPlan => ({ + engineByNodeId: { "agent:keeper": "codex" }, + envFiles: [], + id: "daimon-organization", + instancePaths: resolveInstancePaths( + "daimon", + "daimon-organization", + daimonAdapter.container + ), + meta: daimonAdapter.container, + modelAuthMethods: {}, + modelSecretsRequired: [], + port: daimonAdapter.container.port, + recipeEnv: {}, + runtimeName: "daimon", + runtimeRoot: "/opt/spawnfile/runtime-installs/daimon", + sourceIds: ["agent:keeper"], + targetFiles: [] +}); + +describe("Daimon memory lowering", () => { + const durableBank = createBank("shared", { + kind: "sqlite", + path: "/var/lib/spawnfile/memory/lab/shared/memory.sqlite", + persistence: { mode: "durable" } + }); + + it("emits Daimon's memory block for a durably mounted file-backed bank", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + + const config = await emitConfig(node); + + // Exactly Daimon's OrganizationRuntimeMemory shape: camelCase + // runtimeHomePath plus the optional source discriminator. Daimon's parser + // rejects unknown keys, so this must not grow Pi's snake_case fields. + expect(config.agents[0]!.memory).toEqual({ + runtimeHomePath: "/var/lib/spawnfile/memory/lab/shared", + source: "spawnfile:team:shared" + }); + }); + + it("points the memory block at a path the compiler durably mounts", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + const access = createAccess(durableBank); + + const config = await emitConfig(node); + const bundle = createMemoryArtifactBundle(createPlan(node, access)); + + // A runtime home with no persistent mount is either missing at container + // start or root-owned (the Daimon UID entrypoint only chowns declared + // mount paths), so Mneme's first write would fail. This is the durability + // half of "supported". + expect(bundle.mounts.map((mount) => mount.mount_path)).toContain( + (config.agents[0]!.memory as { runtimeHomePath: string }).runtimeHomePath + ); + }); + + it("hands the memory mount to the Daimon UID ownership repair", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + const config = await emitConfig(node); + const runtimeHomePath = + (config.agents[0]!.memory as { runtimeHomePath: string }).runtimeHomePath; + const bundle = createMemoryArtifactBundle(createPlan(node, createAccess(durableBank))); + + // The other half of durability: a fresh Docker volume is root-owned, and + // Daimon's runtime runs as an unprivileged uid, so Mneme's first mkdir + // under the emitted runtime home only succeeds because the memory mount + // reaches the UID entrypoint's writable state roots. + const ownership = resolveDaimonUidEntrypointOwnershipPlan( + [createRuntimeTargetPlan()], + bundle.mounts.map((mount) => mount.mount_path) + ); + + expect(ownership.stateRoots).toContain(runtimeHomePath); + }); + + it("keeps the memory runtime home isolated from the agent's own paths", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + const instancePaths = resolveInstancePaths( + "daimon", + "daimon-organization", + daimonAdapter.container + ); + + const config = await emitConfig(node); + const agent = config.agents[0]! as { + memory: { runtimeHomePath: string }; + runtimeHomePath: string; + workspacePath: string; + }; + const resolve = (value: string) => value + .replaceAll("", instancePaths.instanceRoot) + .replaceAll("", instancePaths.workspacePath); + + // Daimon's parser rejects a memory runtime home that overlaps the agent + // workspace (a model-writable bash cwd) or any peer runtime home. + for (const other of [resolve(agent.workspacePath), resolve(agent.runtimeHomePath)]) { + expect(agent.memory.runtimeHomePath).not.toBe(other); + expect(agent.memory.runtimeHomePath.startsWith(`${other}/`)).toBe(false); + expect(other.startsWith(`${agent.memory.runtimeHomePath}/`)).toBe(false); + } + }); + + it("reports memory as supported once the block is emitted", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + + expect(await memoryCapabilities(node)).toEqual([ + { + key: "memory", + outcome: "supported", + message: "Daimon lowers Mneme memory bank shared into the organization runtime agent config at /var/lib/spawnfile/memory/lab/shared" + }, + { + key: "memory.shared", + outcome: "supported", + message: "Daimon lowers Mneme memory bank shared into the organization runtime agent config at /var/lib/spawnfile/memory/lab/shared" + } + ]); + }); + + it("emits no memory block and degrades for a store with no durable volume", async () => { + for (const store of [ + { kind: "memory" as const }, + { kind: "postgres" as const, dsn_secret: "MEMORY_DSN" }, + { + kind: "sqlite" as const, + path: "/var/lib/spawnfile/memory/lab/scratch/memory.sqlite", + persistence: { mode: "ephemeral" as const } + } + ]) { + const node = createDaimonNode({ + memoryAccess: [createAccess(createBank("scratch", store))] + }); + + expect((await emitConfig(node)).agents[0]!.memory).toBeUndefined(); + expect((await memoryCapabilities(node))[0]!.outcome).toBe("degraded"); + } + }); + + it("degrades when an agent declares more banks than Daimon can hold", async () => { + const second = createBank("second", { + kind: "sqlite", + path: "/var/lib/spawnfile/memory/lab/second/memory.sqlite", + persistence: { mode: "durable" } + }); + const node = createDaimonNode({ + memoryAccess: [createAccess(durableBank), createAccess(second)] + }); + + const config = await emitConfig(node); + + expect(config.agents[0]!.memory).toEqual({ + runtimeHomePath: "/var/lib/spawnfile/memory/lab/second", + source: "spawnfile:team:second" + }); + expect((await memoryCapabilities(node))[0]!.outcome).toBe("degraded"); + }); + + it("emits a compile diagnostic naming the wired bank and every ignored one", async () => { + // Selection is the lexicographically first declared bank, so declaring a + // bank that sorts earlier silently re-points the agent's memory home and + // orphans the old bank's data. That has to be visible in the compile + // diagnostics, not only as a capability row. + const second = createBank("second", { + kind: "sqlite", + path: "/var/lib/spawnfile/memory/lab/second/memory.sqlite", + persistence: { mode: "durable" } + }); + const node = createDaimonNode({ + memoryAccess: [createAccess(durableBank), createAccess(second)] + }); + + const diagnostics = (await daimonAdapter.compileAgent(node)).diagnostics; + const memoryDiagnostic = diagnostics.find((entry) => entry.message.includes("memory bank")); + + expect(memoryDiagnostic).toBeDefined(); + expect(memoryDiagnostic!.level).toBe("warn"); + expect(memoryDiagnostic!.message).toContain("second"); + expect(memoryDiagnostic!.message).toContain("shared"); + }); + + it("emits no multi-bank diagnostic when only one bank is declared", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + const diagnostics = (await daimonAdapter.compileAgent(node)).diagnostics; + expect(diagnostics.filter((entry) => entry.message.includes("memory bank"))).toEqual([]); + }); + + it("pins the instance-root guard to resolveInstancePaths, not a restated literal", () => { + // The guard constant is the only thing keeping a declared memory store out + // of the subtree Daimon's own container-side parser hard-rejects at boot. + // Derive the expected root from the authority that actually builds those + // paths so the two cannot drift apart silently. + const instanceRoot = resolveInstancePaths( + "daimon", + DAIMON_ORGANIZATION_TARGET_ID, + daimonAdapter.container + ).instanceRoot; + // resolveInstancePaths composes `//`. + const derivedRoot = path.posix.dirname(path.posix.dirname(instanceRoot)); + + expect(DAIMON_INSTANCE_STATE_ROOT).toBe(derivedRoot); + expect(instanceRoot.startsWith(`${DAIMON_INSTANCE_STATE_ROOT}/`)).toBe(true); + }); + + it("rejects a memory store that overlaps the Daimon instance state root", async () => { + const node = createDaimonNode({ + memoryAccess: [createAccess(createBank("collide", { + kind: "sqlite", + path: "/var/lib/spawnfile/instances/daimon/daimon-organization/workspace/memory.sqlite", + persistence: { mode: "durable" } + }))] + }); + + await expect(emitConfig(node)).rejects.toThrow(/overlaps the Daimon instance state root/u); + }); + + /** + * A bank asking for vector recall on a runtime that cannot do it must say so. + * The emitted memory block carries no embedding configuration at all and + * daimon's CLI harness never sets `memory.embeddingProvider`, so Mneme + * silently falls back to lexical-only recall. Without this, the declaration + * is accepted verbatim and quietly means something else. + */ + it("warns that a bank declaring vector recall gets lexical-only recall", async () => { + const vectorBank = createBank("shared", { + kind: "sqlite", + path: "/var/lib/spawnfile/memory/lab/shared/memory.sqlite", + persistence: { mode: "durable" } + }); + const node = createDaimonNode({ + memoryAccess: [createAccess({ + ...vectorBank, + index: { + ...vectorBank.index, + vector: { enabled: true, model: "qwen3-embedding:0.6b", provider: "ollama" } + } + } as ResolvedMemoryBank)] + }); + + const compiled = await daimonAdapter.compileAgent(node); + const config = await emitConfig(node); + + expect(compiled.diagnostics.map((diagnostic) => diagnostic.message)).toContainEqual( + expect.stringContaining("Daimon organization runtime v1 has no vector recall") + ); + expect(await memoryCapabilities(node)).toContainEqual( + expect.objectContaining({ key: "memory", outcome: "degraded" }) + ); + // The warning is the whole fix: nothing about the emitted block changes. + expect(config.agents[0]!.memory).toEqual({ + runtimeHomePath: "/var/lib/spawnfile/memory/lab/shared", + source: "spawnfile:team:shared" + }); + }); + + it("stays silent about vector recall for a bank that never asked for it", async () => { + const node = createDaimonNode({ memoryAccess: [createAccess(durableBank)] }); + const compiled = await daimonAdapter.compileAgent(node); + + expect(compiled.diagnostics.map((diagnostic) => diagnostic.message).join("\n")) + .not.toContain("no vector recall"); + expect(await memoryCapabilities(node)).toContainEqual( + expect.objectContaining({ key: "memory", outcome: "supported" }) + ); + }); +}); diff --git a/src/runtime/daimon/config.ts b/src/runtime/daimon/config.ts index 41a4cf55..934ab5d7 100644 --- a/src/runtime/daimon/config.ts +++ b/src/runtime/daimon/config.ts @@ -8,10 +8,28 @@ import type { ContainerTarget, ContainerTargetInput, EmittedFile } from "../type import { DAIMON_AGY_SUBSCRIPTION_REALM, DAIMON_ENGINE_CREDENTIALS, - DAIMON_GROK_SUBSCRIPTION_REALM + DAIMON_GROK_SUBSCRIPTION_REALM, + DAIMON_GROK_TURN_USAGE_LEDGER } from "./contractManifest.js"; +import { + DAIMON_INSTANCE_STATE_ROOT, + daimonMemoryCapabilityFor, + daimonMemorySelectionWarning, + daimonMemoryVectorRecallWarning, + resolveDaimonAgentMemory +} from "./memory.js"; import { assertDaimonScheduleAuthority } from "./scheduleAuthority.js"; +// Re-exported so every existing importer of these names keeps working; the +// definitions themselves now live in `./memory.js`. +export { + DAIMON_INSTANCE_STATE_ROOT, + daimonMemoryCapabilityFor, + daimonMemorySelectionWarning, + daimonMemoryVectorRecallWarning, + resolveDaimonAgentMemory +}; + export const DAIMON_CONFIG_FILE = "daimon-organization-runtime.json"; export const DAIMON_CONTROL_PORT = 19700; export const DAIMON_MAX_AGENTS = 32; @@ -129,7 +147,9 @@ export const createDaimonContainerTargets = async ( const hasSchedules = agents.some((input) => input.value.schedule !== undefined); if (hasSchedules) await assertDaimonScheduleAuthority(); const configAgents = agents - .map((input) => ({ + .map((input) => { + const memory = resolveDaimonAgentMemory(input.value); + return { engine: { kind: resolveDaimonEngine(input.value) }, id: input.id, instructions: formatInstructions(input.value), @@ -139,6 +159,7 @@ export const createDaimonContainerTargets = async ( ...(server.command ? { command: server.command } : {}), ...(server.url ? { url: server.url } : {}), ...(server.auth?.mode === "bearer" ? { authSecretEnv: server.auth.secret } : {}) })) }), + ...(memory ? { memory } : {}), ...(input.value.surfaces?.moltnet?.length ? { moltnet: { cliPath: "/usr/local/bin/moltnet", configPath: `/agents/${input.slug}/.moltnet/config.json`, @@ -147,7 +168,8 @@ export const createDaimonContainerTargets = async ( runtimeHomePath: `/${DAIMON_RUNTIME_HOMES_DIRECTORY}/${input.slug}`, workspacePath: `/agents/${input.slug}`, ...(hasSchedules ? { schedule: normalizeSchedule(input.value) ?? { kind: "disabled" } } : {}) - })) + }; + }) .sort((left, right) => left.id.localeCompare(right.id)); const engineByNodeId = Object.fromEntries(configAgents.map((agent) => [agent.id, agent.engine.kind])); const hasAgy = configAgents.some((agent) => agent.engine.kind === "agy"); @@ -219,8 +241,29 @@ export const createDaimonContainerTargets = async ( 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. + 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 ? [{ 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 + // in this volume. Without this lifecycle the volume name folds in the + // run id, so every `spawnfile up` would hand the container an empty + // keyring and the operator would have to re-enrol by hand. + lifecycle: "exclusive-reattach" as const, mountPath: DAIMON_AGY_SUBSCRIPTION_REALM.durableMountPath, reason: "Daimon host AGY subscription realm" }, ...agyRuntimeHomeMounts] : [])], diff --git a/src/runtime/daimon/memory.ts b/src/runtime/daimon/memory.ts new file mode 100644 index 00000000..f33419e3 --- /dev/null +++ b/src/runtime/daimon/memory.ts @@ -0,0 +1,159 @@ +import type { ResolvedAgentNode } from "../../compiler/types.js"; +import type { CapabilityReport } from "../../report/index.js"; +import { resolveMnemeDurableMemoryMountPath } from "../mnemeMcp.js"; +import { SpawnfileError } from "../../shared/index.js"; + +/** + * Memory lowering for the Daimon organization runtime, split out of + * `config.ts` so that file stays inside the repository's 400-line source + * bound. Nothing here knows about container targets, engines, or mounts: it + * answers one question — which single declared Mneme bank (if any) becomes the + * agent's `memory` block, and what the operator is told when the answer is not + * the one they declared. + */ + +type DaimonMemoryAccess = NonNullable[number]; + +/** + * Daimon's organization runtime accepts exactly one `memory` block per agent + * (`OrganizationRuntimeMemory` = `{ runtimeHomePath, source?, tokenBudget? }`), + * so lowering picks one declared bank deterministically, the same way the + * legacy generated-Pi emitter does in `../pi/appAgentConfig.ts`. + */ +const memoryAccessKey = (access: DaimonMemoryAccess): string => + `${access.source}:${access.bank.id}`; + +const selectDaimonMemoryAccess = ( + node: ResolvedAgentNode +): DaimonMemoryAccess | undefined => + [...(node.memoryAccess ?? [])].sort((left, right) => + memoryAccessKey(left).localeCompare(memoryAccessKey(right)) + )[0]; + +/** + * The instance root every Daimon agent's `workspacePath` and `runtimeHomePath` + * resolve under (`resolveInstancePaths` in + * `src/compiler/containerTargetPlanResolution.ts`). Daimon's own parser rejects + * a `memory.runtimeHomePath` that overlaps either of those, so a declared store + * inside this subtree must fail at compile time with a Spawnfile message rather + * than as an opaque container-side config parse error. + * + * Restating the literal here is deliberate — importing the compiler's path + * builder into a runtime adapter would invert the dependency direction — so it + * is pinned to that authority by `config.test.ts`'s "pins the instance-root + * guard to resolveInstancePaths" case, which derives the expected value from + * `resolveInstancePaths` and fails if either side drifts. + */ +export const DAIMON_INSTANCE_STATE_ROOT = "/var/lib/spawnfile/instances"; + +export const resolveDaimonAgentMemory = ( + node: ResolvedAgentNode +): { runtimeHomePath: string; source: string } | undefined => { + const access = selectDaimonMemoryAccess(node); + if (!access) return undefined; + const runtimeHomePath = resolveMnemeDurableMemoryMountPath(access.bank); + if (runtimeHomePath === null) return undefined; + if ( + runtimeHomePath === DAIMON_INSTANCE_STATE_ROOT || + runtimeHomePath.startsWith(`${DAIMON_INSTANCE_STATE_ROOT}/`) || + DAIMON_INSTANCE_STATE_ROOT.startsWith(`${runtimeHomePath}/`) + ) { + throw new SpawnfileError( + "validation_error", + `Daimon memory bank ${access.bank.id} resolves to ${runtimeHomePath}, which overlaps the Daimon instance state root ${DAIMON_INSTANCE_STATE_ROOT}; declare the store outside it` + ); + } + return { + runtimeHomePath, + source: `spawnfile:${access.declaringKind}:${access.bank.id}` + }; +}; + +/** + * The compile-time warning for an agent that declared several memory banks. + * + * `selectDaimonMemoryAccess` is deterministic (lexicographically first key), + * and the capability row already reports the outcome as `degraded` — but a + * capability row does not say *which* bank won. Declaring a new bank whose key + * sorts earlier silently re-points this agent's memory home and orphans the + * previously wired bank's data, so the wired and ignored ids are named here, + * in the diagnostics an operator reads on every compile. The selection rule + * itself is unchanged. + */ +export const daimonMemorySelectionWarning = ( + node: ResolvedAgentNode +): string | undefined => { + const accesses = node.memoryAccess ?? []; + const selected = selectDaimonMemoryAccess(node); + if (!selected) return undefined; + const selectedKey = memoryAccessKey(selected); + const ignored = [...new Set( + accesses + .filter((access) => memoryAccessKey(access) !== selectedKey) + .map((access) => access.bank.id) + )].sort(); + if (ignored.length === 0) return undefined; + return `Daimon organization runtime v1 lowers one memory bank per agent: ${node.name} wires memory bank ${selected.bank.id} and ignores ${ignored.join(", ")}. The wired bank is the lexicographically first declared one, so adding a bank that sorts earlier re-points this agent's memory home and orphans ${selected.bank.id}'s data.`; +}; + +/** + * The compile-time warning for a wired memory bank that asked for vector + * recall the Daimon organization runtime cannot provide. + * + * The memory block Spawnfile lowers into the organization runtime config is + * `{runtimeHomePath, source?, tokenBudget?}` and nothing more (see + * `resolveDaimonAgentMemory` above and the `memory` schema in daimon's + * `organizationRuntimeContract.ts`). Daimon's CLI harness path forwards exactly + * those three fields and never sets `memory.embeddingProvider`, so Mneme builds + * a lexical-only recall path. Embeddings are optional in Mneme and it falls back + * safely, so a fixture declaring `index.vector.enabled: true` still compiles and + * still recalls — just not the way it asked to. That silence is the defect: the + * declaration is accepted verbatim and quietly means something else. + * + * This says so at compile time rather than forwarding the configuration, + * because forwarding it would mean widening the daimon organization runtime + * config contract (and its digest-pinned manifest) plus building an embedding + * provider on the daimon side — a much larger change than telling the truth. + */ +export const daimonMemoryVectorRecallWarning = ( + node: ResolvedAgentNode +): string | undefined => { + const access = selectDaimonMemoryAccess(node); + if (!access || !resolveDaimonAgentMemory(node)) return undefined; + const vector = access.bank.index?.vector; + if (!vector?.enabled) return undefined; + return `Daimon organization runtime v1 has no vector recall: memory bank ${access.bank.id} declares ` + + `index.vector.enabled with model ${vector.model ?? "(unset)"}, but the runtime memory contract carries ` + + "no embedding configuration, so recall for this agent is lexical only. Declare index.vector on a " + + "runtime that lowers it, or drop it from this bank so the declaration matches the behavior."; +}; + +export const daimonMemoryCapabilityFor = ( + node: ResolvedAgentNode +): { memoryMessage?: string; memoryOutcome?: CapabilityReport["outcome"] } => { + const access = selectDaimonMemoryAccess(node); + if (!access) return {}; + const memory = resolveDaimonAgentMemory(node); + if (!memory) { + return { + memoryMessage: `Daimon organization runtime v1 lowers only durably mounted file-backed Mneme banks; memory bank ${access.bank.id} (store ${access.bank.store.kind}) has no durable container volume, so no memory block is emitted`, + memoryOutcome: "degraded" + }; + } + const declaredKeys = new Set((node.memoryAccess ?? []).map(memoryAccessKey)); + declaredKeys.delete(memoryAccessKey(access)); + if (declaredKeys.size > 0) { + return { + memoryMessage: `Daimon organization runtime v1 lowers one memory bank per agent; ${access.bank.id} is wired at ${memory.runtimeHomePath} and ${declaredKeys.size} further declared bank(s) are not lowered`, + memoryOutcome: "degraded" + }; + } + const vectorWarning = daimonMemoryVectorRecallWarning(node); + if (vectorWarning) { + return { memoryMessage: vectorWarning, memoryOutcome: "degraded" }; + } + return { + memoryMessage: `Daimon lowers Mneme memory bank ${access.bank.id} into the organization runtime agent config at ${memory.runtimeHomePath}`, + memoryOutcome: "supported" + }; +}; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 947467cc..32fa4b88 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -6,3 +6,5 @@ export * from "./localDaimonAuthority.js"; export * from "./registry.js"; export * from "./statusProbes.js"; export * from "./types.js"; +export * from "./usageLedger.js"; +export * from "./usageLedgerRead.js"; diff --git a/src/runtime/mnemeMcp.ts b/src/runtime/mnemeMcp.ts index 3c1570c9..1cc25d77 100644 --- a/src/runtime/mnemeMcp.ts +++ b/src/runtime/mnemeMcp.ts @@ -29,6 +29,40 @@ export const resolveMnemeMemoryRuntimeHomePath = (access: MnemeMemoryAccess): st export const isMnemeMemoryAccessSupported = (access: MnemeMemoryAccess): boolean => resolveMnemeMemoryRuntimeHomePath(access) !== null; +const normalizePosixPath = (value: string): string => + path.normalize(value).replace(/\/+$/u, "") || "/"; + +/** + * The container path a bank's Mneme runtime home is durably mounted at, or + * `null` when the compiler emits no persistent volume for it. + * + * This is the single authority shared by two sides that must never disagree: + * `src/compiler/memoryArtifacts.ts`, which emits the persistent mount (and + * therefore the directory the Daimon UID entrypoint creates and chowns to the + * runtime uid), and the runtime config emitters, which may only point an + * in-process Mneme runtime at a path the container actually mounts. A path + * without a mount is either absent at runtime or root-owned, so an in-process + * runtime pointed at one fails its first `mkdir`/write instead of degrading. + */ +export const resolveMnemeDurableMemoryMountPath = ( + bank: MnemeMemoryAccess["bank"] +): string | null => { + const store = bank.store; + if (store.kind !== "sqlite" && store.kind !== "json") { + return null; + } + + if (store.persistence?.mode === "ephemeral") { + return null; + } + + if (store.persistence?.mount) { + return normalizePosixPath(store.persistence.mount); + } + + return store.path ? path.dirname(normalizePosixPath(store.path)) : null; +}; + const memoryAccessKey = (access: MnemeMemoryAccess): string => `${access.source}:${access.bank.id}`; From f3ccf04df9dcc839c95cc414b2bb70bf797ad458 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:25 +0200 Subject: [PATCH 20/34] feat(usage): aggregate per-agent and per-provider token usage behind spawnfile usage --- src/cli/AGENTS.md | 2 + src/cli/runCli.ts | 4 + src/cli/usageCommand.test.ts | 396 ++++++++++++++++++ src/cli/usageCommand.ts | 373 +++++++++++++++++ src/cli/usageCommandLive.ts | 173 ++++++++ .../containerDaimonBrokerRender.test.ts | 26 ++ src/compiler/containerDaimonBrokerRender.ts | 8 +- ...containerDaimonUidEntrypointRender.test.ts | 35 ++ .../containerDaimonUidEntrypointRender.ts | 7 +- src/deployment/dockerProbeGateway.test.ts | 118 +++++- src/deployment/dockerProbeGateway.ts | 26 +- src/runtime/usageLedger.test.ts | 264 ++++++++++++ src/runtime/usageLedger.ts | 302 +++++++++++++ src/runtime/usageLedgerRead.test.ts | 134 ++++++ src/runtime/usageLedgerRead.ts | 139 ++++++ src/status/runtimeProbes.test.ts | 5 +- 16 files changed, 1999 insertions(+), 13 deletions(-) create mode 100644 src/cli/usageCommand.test.ts create mode 100644 src/cli/usageCommand.ts create mode 100644 src/cli/usageCommandLive.ts create mode 100644 src/runtime/usageLedger.test.ts create mode 100644 src/runtime/usageLedger.ts create mode 100644 src/runtime/usageLedgerRead.test.ts create mode 100644 src/runtime/usageLedgerRead.ts diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 874e7b4f..fa3d7937 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -21,6 +21,8 @@ src/cli/ ├── statusCommand.ts # Status command orchestration and registration ├── statusCommandOptions.ts # Status option parsing, handler contracts, and output helpers ├── statusCommandLive.ts # Home-store and live-deployment status collection +├── usageCommand.ts # `spawnfile usage` registration, windowing, and rendering +├── usageCommandLive.ts # Usage ledger transport: deployment selection + probe-gateway reads ├── modelCommands.ts # `spawnfile model ...` command registration ├── runtimeCommands.ts # `spawnfile runtime ...` command registration ├── surfaceCommands.ts # `spawnfile surface ...` command registration diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index b642e103..31f5d991 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -55,6 +55,7 @@ import { registerModelCommands } from "./modelCommands.js"; import { registerRuntimeCommands } from "./runtimeCommands.js"; import { registerSurfaceCommands } from "./surfaceCommands.js"; import { registerStatusCommand } from "./statusCommand.js"; +import { registerUsageCommand } from "./usageCommand.js"; import { registerProductionTargetCommands } from "./targetProductionCommands.js"; import { registerViewCommand } from "./viewCommand.js"; @@ -317,6 +318,9 @@ export const runCli: RunCli = async ( registerStatusCommand(program, handlers, streams, (exitCode) => { commandExitCode = exitCode; }); + registerUsageCommand(program, streams, (exitCode) => { + commandExitCode = exitCode; + }); registerViewCommand(program, handlers, streams, cliOptions.renderEnvironment); registerProductionTargetCommands(program, streams, cliOptions.stdin, (exitCode) => { commandExitCode = exitCode; diff --git a/src/cli/usageCommand.test.ts b/src/cli/usageCommand.test.ts new file mode 100644 index 00000000..e6f23b47 --- /dev/null +++ b/src/cli/usageCommand.test.ts @@ -0,0 +1,396 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { DeploymentRecord } from "../deployment/index.js"; +import { SpawnfileError } from "../shared/index.js"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; + +import { executeUsageCommand } from "./usageCommand.js"; +import { collectOrganizationUsage, selectUsageDeployment } from "./usageCommandLive.js"; + +const line = (overrides: Record = {}): string => JSON.stringify({ + v: "noopolis.daimon.turn-usage.v1", + agent: "cogsworth", + wake: "wake-1", + engine: "grok", + at: new Date().toISOString(), + input: 8_746, + output: 29, + cache_read: 5_760, + cache_write: 0, + total: 14_535, + calls: 1, + notional_usd: 0.0035, + complete: true, + ...overrides +}); + +const record = (units: Partial[] = [{}]): DeploymentRecord => ({ + auth_profile: null, + compile_fingerprint: "fingerprint", + created_at: new Date().toISOString(), + manager: "docker", + name: "daimon-organization", + output_directory: "/tmp/out", + source: { kind: "project", root: "/tmp/project" }, + target: { kind: "host", value: "unix:///var/run/docker.sock" }, + units: units.map((unit, index) => ({ + container_id: `container-${index}`, + container_name: `spawnfile-${index}`, + contains: [ + { id: "cogsworth", kind: "agent" as const }, + { id: "foreman", kind: "agent" as const }, + { id: "brass", kind: "agent" as const } + ], + id: `unit-${index}`, + image_id: null, + image_tag: "spawnfile/daimon:latest", + kind: "container" as const, + runtime_instances: ["daimon-organization"], + ...unit + })), + version: "spawnfile.deployment.v2" +}) as DeploymentRecord; + +const inspection = (running: boolean | null) => new Map([["unit-0", { + containerId: "container-0", drift: [], exists: running !== null, exitCode: null, + finishedAt: null, identity: null, imageId: null, message: "", restartCount: null, + running, severity: "ok" as const, startedAt: null, status: null, unitId: "unit-0" +}]]); + +const handlersFor = (stdoutByPath: Record, running: boolean | null = true) => ({ + createDockerProbeGateway: (() => ({ + exec: async (command: string[]) => { + const target = command[1]!; + if (!(target in stdoutByPath)) throw new Error("docker probe exit 1: No such file or directory"); + return { stderr: "", stdout: stdoutByPath[target]! }; + }, + httpGet: async () => ({ body: "", ok: true }), + inspectUnit: async () => { throw new Error("unused"); } + })) as never, + inspectDockerDeployment: (async () => inspection(running)) as never, + listDeploymentRecords: (async () => [{ path: "/tmp/out/record.json", record: record() }]) as never +}); + +describe("selectUsageDeployment", () => { + it("reports a helpful error when nothing is deployed", () => { + expect(selectUsageDeployment([])).toEqual({ error: expect.stringContaining("No deployment records") }); + }); + + it("requires --deployment when several records exist", () => { + const two = [{ record: record() }, { record: { ...record(), name: "other" } }]; + expect(selectUsageDeployment(two)).toEqual({ error: expect.stringContaining("requires --deployment") }); + expect(selectUsageDeployment(two, "other")).toMatchObject({ name: "other" }); + expect(selectUsageDeployment(two, "absent")).toEqual({ error: expect.stringContaining("Unknown deployment") }); + }); +}); + +describe("collectOrganizationUsage", () => { + it("reads both ledger generations through the probe gateway", async () => { + const result = await collectOrganizationUsage({ outputDirectory: "/tmp/out" }, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line()}\n`, + [DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath]: `${line({ wake: "rotated" })}\n` + })); + expect("error" in result).toBe(false); + const usage = result as Exclude; + expect(usage.records.map((entry) => entry.wake).sort()).toEqual(["rotated", "wake-1"]); + expect(usage.roster.map((entry) => entry.agent)).toEqual(["brass", "cogsworth", "foreman"]); + }); + + it("renders a missing ledger as empty rather than an error", async () => { + const result = await collectOrganizationUsage({ outputDirectory: "/tmp/out" }, handlersFor({})); + expect(result).toMatchObject({ records: [], unreadableUnits: [] }); + }); + + it("reports a stopped container as unreadable rather than as zero usage", async () => { + const result = await collectOrganizationUsage({ outputDirectory: "/tmp/out" }, handlersFor({}, false)); + const usage = result as Exclude; + expect(usage.records).toEqual([]); + expect(usage.unreadableUnits).toEqual([{ containerRef: "container-0", reason: "stopped", unitId: "unit-0" }]); + }); + + it("reports a rotated generation that overran the read buffer as unreadable, not as zero usage", async () => { + const result = await collectOrganizationUsage({ outputDirectory: "/tmp/out" }, { + ...handlersFor({}), + createDockerProbeGateway: (() => ({ + exec: async (command: string[]) => { + if (command[1] === DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath) { + throw Object.assign(new Error("stdout maxBuffer length exceeded"), { + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" + }); + } + return { stderr: "", stdout: `${line()}\n` }; + }, + httpGet: async () => ({ body: "", ok: true }), + inspectUnit: async () => { throw new Error("unused"); } + })) as never + }); + const usage = result as Exclude; + expect(usage.records).toHaveLength(1); + expect(usage.unreadableUnits).toEqual([{ + containerRef: "container-0", + detail: `${DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath}: stdout maxBuffer length exceeded`, + reason: "ledger_read_failed", + unitId: "unit-0" + }]); + }); +}); + +describe("spawnfile usage", () => { + it("renders PARTIAL coverage, an engine rollup, and the lower-bound caveat", async () => { + const result = await executeUsageCommand("/tmp/project", {}, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line()}\n${line({ agent: "foreman", wake: "w2", total: 1_400_000, notional_usd: 4.3 })}\n` + })); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("ORG daimon-organization · last 24h · coverage PARTIAL (2 of 3 agents)"); + expect(result.output).toContain("brass"); + expect(result.output).toMatch(/grok\s+\S*2/u); + expect(result.output).toContain("Counts are a lower bound"); + }); + + it("reports a mixed AGY and Grok organization with both engines rolled up", async () => { + const result = await executeUsageCommand("/tmp/project", {}, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: [ + line(), + line({ agent: "foreman", wake: "w2", engine: "agy", input: 44_937, output: 444, cache_read: 0, total: 45_381, notional_usd: 0 }) + ].join("\n") + "\n" + })); + expect(result.exitCode).toBe(0); + expect(result.output).toMatch(/^foreman\s+agy\s/mu); + expect(result.output).toMatch(/^cogsworth\s+grok\s/mu); + expect(result.output).toMatch(/^agy\s/mu); + expect(result.output).toMatch(/^grok\s/mu); + // AGY's result frame carries no `total_cost_usd`, so its notional column is + // structurally unknown. Rendering it as `$0.00` would advertise a free turn + // on a subscription that was in fact spent. + expect(result.output).toMatch(/^foreman\s+agy\s+\S+\s+45\.4k\s+—/mu); + expect(result.output).toMatch(/^agy\s+\S*\s*1\s+45\.4k\s+—/mu); + }); + + it("counts an all-zero turn as unknown rather than free", async () => { + const result = await executeUsageCommand("/tmp/project", {}, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line({ complete: false, input: 0, output: 0, cache_read: 0, cache_write: 0, total: 0 })}\n` + })); + expect(result.output).toContain("reported all-zero usage and are counted as unknown, not free"); + }); + + it("windows by --since and drops records outside it", async () => { + const stale = new Date(Date.now() - 48 * 60 * 60 * 1_000).toISOString(); + const handlers = handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line({ at: stale })}\n${line({ agent: "foreman", wake: "w2" })}\n` + }); + const day = await executeUsageCommand("/tmp/project", { json: true }, handlers); + expect(JSON.parse(day.output!).coverage.agentsReporting).toBe(1); + const week = await executeUsageCommand("/tmp/project", { json: true, since: "7d" }, handlers); + expect(JSON.parse(week.output!).coverage.agentsReporting).toBe(2); + }); + + it("filters to one agent and caps the table with --top", async () => { + const handlers = handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line()}\n${line({ agent: "foreman", wake: "w2" })}\n` + }); + const one = await executeUsageCommand("/tmp/project", { agent: "foreman", json: true }, handlers); + expect(JSON.parse(one.output!).byEngine[0].turns).toBe(1); + const top = await executeUsageCommand("/tmp/project", { top: "1" }, handlers); + expect(top.output).not.toContain("brass"); + }); + + it("emits machine-readable JSON that declares its counts a lower bound", async () => { + const result = await executeUsageCommand("/tmp/project", { json: true }, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line()}\n` + })); + const parsed = JSON.parse(result.output!); + expect(parsed).toMatchObject({ version: "spawnfile.usage.v1", deployment: "daimon-organization", lowerBound: true, since: "24h" }); + expect(parsed.coverage).toMatchObject({ agentsReporting: 1, agentsTotal: 3, partial: true }); + }); + + it("rejects malformed options without reading anything", async () => { + const listDeploymentRecords = vi.fn(); + for (const options of [{ since: "soon" }, { by: "team" }, { top: "0" }, { timeout: "later" }]) { + const result = await executeUsageCommand("/tmp/project", options, { listDeploymentRecords: listDeploymentRecords as never }); + expect(result.exitCode).toBe(2); + expect(result.error).toMatch(/Invalid/u); + } + expect(listDeploymentRecords).not.toHaveBeenCalled(); + }); + + it("marks the window PARTIAL and prints an UNREADABLE row when a ledger read fails", async () => { + // A dead subscription must never be reported as a cheap one: an + // unreadable generation is UNKNOWN usage, not zero usage. + const handlers = { + ...handlersFor({}), + createDockerProbeGateway: (() => ({ + exec: async (command: string[]) => { + if (command[1] === DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath) { + throw Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM", stderr: "" }); + } + return { + stderr: "", + stdout: `${line()}\n${line({ agent: "foreman", wake: "w2" })}\n${line({ agent: "brass", wake: "w3" })}\n` + }; + }, + httpGet: async () => ({ body: "", ok: true }), + inspectUnit: async () => { throw new Error("unused"); } + })) as never + }; + + const table = await executeUsageCommand("/tmp/project", {}, handlers); + expect(table.output).toContain("coverage PARTIAL"); + expect(table.output).toContain("UNREADABLE unit-0 (container-0): ledger read failed"); + + const json = await executeUsageCommand("/tmp/project", { json: true }, handlers); + const parsed = JSON.parse(json.output!); + expect(parsed.coverage).toMatchObject({ agentsReporting: 3, agentsTotal: 3, partial: true, unreadableUnitCount: 1 }); + expect(parsed.unreadableUnits[0]).toMatchObject({ reason: "ledger_read_failed" }); + }); + + it("separates a runtime failure (exit 1) from a usage/input failure (exit 2)", async () => { + // errorExitCode's contract (specs/SPEC.md §9.1, shared across every + // command): 2 for usage/input errors, 1 for runtime failures. A Docker + // read blowing up is not the operator mistyping a flag. + const runtime = await executeUsageCommand("/tmp/project", {}, { + listDeploymentRecords: (async () => { throw new Error("docker daemon unreachable"); }) as never + }); + expect(runtime).toMatchObject({ error: "docker daemon unreachable", exitCode: 1 }); + + const usageError = await executeUsageCommand("/tmp/project", {}, { + listDeploymentRecords: (async () => { + throw new SpawnfileError("validation_error", "deployment record is malformed"); + }) as never + }); + expect(usageError).toMatchObject({ error: "deployment record is malformed", exitCode: 2 }); + }); + + it("groups by engine when asked", async () => { + const result = await executeUsageCommand("/tmp/project", { by: "engine" }, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: `${line()}\n` + })); + expect(result.output).not.toContain("cogsworth"); + expect(result.output).toContain("grok"); + }); +}); + +describe("spawnfile usage --exported", () => { + const exportRoots: string[] = []; + afterEach(async () => { + await Promise.all(exportRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); + }); + + const writeExport = async (generations: Record): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-usage-export-")); + exportRoots.push(root); + await mkdir(path.join(root, "spawnfile"), { recursive: true }); + await writeFile(path.join(root, "spawnfile", "export-index.json"), JSON.stringify({ + version: "spawnfile.export-index.v1", + run_id: "run-42", + deployment: "daimon-organization", + exported_at: new Date().toISOString(), + files: [] + })); + if (Object.keys(generations).length > 0) { + await mkdir(path.join(root, "raw", "daimon"), { recursive: true }); + for (const [name, content] of Object.entries(generations)) { + await writeFile(path.join(root, "raw", "daimon", name), content); + } + } + return root; + }; + + const recordsOnly = { listDeploymentRecords: (async () => [{ path: "/tmp/out/record.json", record: record() }]) as never }; + + /** + * The real property is EQUIVALENCE: identical ledger bytes must aggregate to + * the same answer whether they arrive from `docker exec cat` or from a sealed + * export. Asserting that directly, rather than hardcoding totals in two + * places, is what proves the export path reuses the aggregation layer instead + * of quietly growing a second one that can drift. + */ + it("aggregates an exported run identically to the same bytes read live", async () => { + const primary = `${line()}\n${line({ wake: "wake-2", total: 2_000 })}\n`; + const rotated = `${line({ wake: "rotated", agent: "foreman", total: 900 })}\n`; + + const live = await executeUsageCommand("/tmp/project", { json: true, out: "/tmp/out" }, handlersFor({ + [DAIMON_GROK_TURN_USAGE_LEDGER.filePath]: primary, + [DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath]: rotated + })); + const exported = await executeUsageCommand("/tmp/project", { + exported: await writeExport({ "usage.jsonl": primary, "usage.jsonl.1": rotated }), + json: true, + out: "/tmp/out" + }, recordsOnly); + + expect(live.exitCode).toBe(0); + expect(exported.exitCode).toBe(0); + const stripSource = (output: string) => { + const parsed = JSON.parse(output) as Record; + delete parsed.source; + return parsed; + }; + expect(stripSource(exported.output!)).toEqual(stripSource(live.output!)); + // Guard against the equivalence passing because both sides are empty. + expect((stripSource(live.output!) as { byEngine: unknown[] }).byEngine.length).toBeGreaterThan(0); + }); + + /** + * `usage.jsonl.1` is the OLDER generation and must be read first. + * + * Aggregation is almost entirely order-independent -- sums and counts do not + * care -- so most assertions here would pass with the generations reversed. + * The one observable that does care is engine attribution: `groupUsageByAgent` + * takes an agent's engine from the FIRST record it sees, so an agent that + * changed engine across a rotation reports the engine it started the window + * on. Reversing the generations silently reattributes it, which is exactly the + * plausible-looking wrong answer worth pinning. + */ + it("orders the rotated generation before the current one", async () => { + const exported = await executeUsageCommand("/tmp/project", { + exported: await writeExport({ + "usage.jsonl": `${line({ engine: "agy", wake: "current" })}\n`, + "usage.jsonl.1": `${line({ engine: "grok", wake: "older" })}\n` + }), + json: true, + out: "/tmp/out" + }, recordsOnly); + + const byAgent = JSON.parse(exported.output!).byAgent as { agent: string; engine: string | null; turns: number }[]; + const cogsworth = byAgent.find((row) => row.agent === "cogsworth")!; + expect(cogsworth.turns).toBe(2); + // The older generation is read first, so the window opens on grok. + expect(cogsworth.engine).toBe("grok"); + }); + + it("reports an export carrying no ledger as unknown, never as zero cost", async () => { + const result = await executeUsageCommand("/tmp/project", { + exported: await writeExport({}), + out: "/tmp/out" + }, recordsOnly); + + expect(result.exitCode).toBe(0); + // Absent, not empty-and-complete: coverage must degrade and no dollar + // figure may be asserted over a ledger nobody read. + expect(result.output).toContain("coverage PARTIAL"); + expect(result.output).toContain("is not in this export"); + expect(result.output).toContain("no metered turns in this window"); + expect(result.output).not.toMatch(/\$\d/u); + }); + + it("names the source it read so the choice is never left implicit", async () => { + const root = await writeExport({ "usage.jsonl": `${line()}\n` }); + const exported = await executeUsageCommand("/tmp/project", { exported: root, out: "/tmp/out" }, recordsOnly); + expect(exported.output).toContain(`source exported ${root}`); + + const live = await executeUsageCommand("/tmp/project", { out: "/tmp/out" }, handlersFor({})); + expect(live.output).not.toContain("source exported"); + }); + + it("rejects a directory that is not a Spawnfile export", async () => { + const notAnExport = await mkdtemp(path.join(os.tmpdir(), "spawnfile-usage-notexport-")); + exportRoots.push(notAnExport); + const result = await executeUsageCommand("/tmp/project", { exported: notAnExport, out: "/tmp/out" }, recordsOnly); + expect(result.exitCode).toBe(2); + expect(result.error).toContain("Not a Spawnfile export directory"); + }); +}); diff --git a/src/cli/usageCommand.ts b/src/cli/usageCommand.ts new file mode 100644 index 00000000..9e789887 --- /dev/null +++ b/src/cli/usageCommand.ts @@ -0,0 +1,373 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { Command } from "commander"; + +import { listDeploymentRecords, parseExportIndex } from "../deployment/index.js"; +import { resolveProjectOutputDirectory } from "../filesystem/index.js"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; +import { readUsageLedgerViaExec, type UsageLedgerExec } from "../runtime/usageLedgerRead.js"; +import { DEFAULT_OUTPUT_DIRECTORY, errorExitCode } from "../shared/index.js"; +import { + computeUsageCoverage, + DEFAULT_USAGE_SINCE, + filterUsageRecordsSince, + groupUsageByAgent, + groupUsageByEngine, + parseUsageSinceDuration, + type UsageAgentGroup, + type UsageEngineGroup, + type UsageRecord +} from "../runtime/usageLedger.js"; + +import type { CliStreams } from "./runCli.js"; +import { + collectOrganizationUsage, + rosterForRecord, + selectUsageDeployment, + type OrganizationUsage, + type UsageCommandLiveHandlers, + type UsageUnitReadFailure +} from "./usageCommandLive.js"; + +/** + * `spawnfile usage` — what did this organization consume. + * + * A separate command from `status` on purpose. `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. + * + * Every number this command prints is a LOWER BOUND. Neither metered engine's + * stream carries an incompleteness marker — grok's `streaming-messages-json` + * and AGY's `stream-json` both zero-fill a bucket they cannot account for — so + * a turn whose usage was partially zero-filled sums to a plausible total and is + * indistinguishable from a real one. Counts are therefore reported as `>=`, and + * coverage is always stated explicitly. AGY additionally reports no cost at + * all, so its notional column is always `—`. + */ +export interface UsageCommandOptions { + agent?: string; + exported?: string; + by?: string; + deployment?: string; + dockerCommand?: string; + json?: boolean; + out?: string; + since?: string; + timeout?: string; + top?: string; +} + +export interface UsageCommandResult { + error?: string; + /** The shared CLI convention (specs/SPEC.md §9.1, `errorExitCode`): 0 on + * success, 2 for a usage/input error, 1 for a runtime failure that surfaced + * after validation. */ + exitCode: 0 | 1 | 2; + output?: string; +} + +const inputFailure = (message: string): UsageCommandResult => ({ error: message, exitCode: 2 }); + +const formatTokens = (value: number): string => { + if (value === 0) return "—"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(value); +}; + +/** + * `—` means unknown, never free. + * + * A zero notional amount is never a claim worth printing: AGY's terminal frame + * carries no `total_cost_usd` at all, and Grok's decoder falls back to `0` for + * a cost it could not read. Rendering either as `$0.00` would advertise a free + * turn on a subscription that is being spent, which is precisely the silent + * loss this command exists to prevent. + */ +const formatUsd = (value: number, turns: number): string => + turns === 0 || value === 0 ? "—" : `$${value.toFixed(2)}`; + +const formatShare = (value: number, total: number): string => + total === 0 ? "—" : `${Math.round((value / total) * 100)}%`; + +/** An unreadable unit is UNKNOWN usage, never zero usage, so it always gets + * its own line and always forces the window's coverage to PARTIAL. */ +const renderUnreadableUnit = (unit: UsageUnitReadFailure): string => + unit.reason === "ledger_read_failed" + ? `UNREADABLE ${unit.unitId} (${unit.containerRef}): ledger read failed (${unit.detail ?? "no detail"}); its usage is unknown, not zero.` + : `UNREADABLE ${unit.unitId} (${unit.containerRef}): container ${unit.reason}; its ledger is not included.`; + +const pad = (value: string, width: number): string => value.padEnd(width); +const padStart = (value: string, width: number): string => value.padStart(width); + +const renderTable = ( + usage: OrganizationUsage, + windowed: UsageRecord[], + options: UsageCommandOptions +): string => { + const groupBy = options.by ?? "agent"; + const coverage = computeUsageCoverage(windowed, usage.roster.length, usage.unreadableUnits.length); + const totalTokens = windowed.reduce((sum, record) => sum + record.total, 0); + const lines: string[] = []; + + const coverageLabel = coverage.partial + ? `coverage PARTIAL (${coverage.agentsReporting} of ${coverage.agentsTotal} agents)` + : `coverage ${coverage.agentsReporting} of ${coverage.agentsTotal} agents`; + const sourceLabel = options.exported === undefined ? "" : ` · source exported ${options.exported}`; + lines.push(`ORG ${usage.deploymentName} · last ${options.since ?? DEFAULT_USAGE_SINCE}${sourceLabel} · ${coverageLabel}`); + lines.push(""); + + if (groupBy === "agent") { + let rows: UsageAgentGroup[] = groupUsageByAgent(windowed, usage.roster) + .sort((left, right) => right.tokens - left.tokens || left.agent.localeCompare(right.agent)); + if (options.agent) rows = rows.filter((row) => row.agent === options.agent); + if (options.top) rows = rows.slice(0, Number(options.top)); + + const width = Math.max(8, ...rows.map((row) => row.agent.length)); + lines.push(`${pad("agent", width)} ${pad("engine", 8)}${padStart("turns", 7)}${padStart("tokens", 9)}${padStart("notional", 11)}${padStart("share", 7)}`); + for (const row of rows) { + lines.push(`${pad(row.agent, width)} ${pad(row.engine ?? "—", 8)}${padStart(row.turns === 0 ? "—" : String(row.turns), 7)}${padStart(formatTokens(row.tokens), 9)}${padStart(formatUsd(row.notionalUsd, row.turns), 11)}${padStart(formatShare(row.tokens, totalTokens), 7)}`); + } + lines.push("─".repeat(width + 44)); + } + + const engineRows: UsageEngineGroup[] = groupUsageByEngine(windowed) + .sort((left, right) => right.tokens - left.tokens || left.engine.localeCompare(right.engine)); + const engineWidth = Math.max(8, ...engineRows.map((row) => row.engine.length)); + for (const row of engineRows) { + lines.push(`${pad(row.engine, engineWidth)} ${pad("", 8)}${padStart(String(row.turns), 7)}${padStart(formatTokens(row.tokens), 9)}${padStart(formatUsd(row.notionalUsd, row.turns), 11)}`); + } + if (engineRows.length === 0) lines.push("no metered turns in this window"); + + lines.push(""); + lines.push("Counts are a lower bound: the engine stream carries no completeness marker."); + if (coverage.incompleteRecordCount > 0) { + lines.push(`${coverage.incompleteRecordCount} turn(s) reported all-zero usage and are counted as unknown, not free.`); + } + for (const unit of usage.unreadableUnits) { + lines.push(renderUnreadableUnit(unit)); + } + return lines.join("\n"); +}; + +/** Where `artifactsExportPlan.ts` lands the ledger inside an exported run + * directory. Both halves are derived from the same pinned contract constant the + * export plan derives them from, so the two cannot drift apart. */ +const EXPORTED_USAGE_DIRECTORY = "raw/daimon"; +const exportedLedgerPaths = (exportedDirectory: string) => { + const resolve = (absoluteContainerPath: string): string => path.join( + exportedDirectory, + EXPORTED_USAGE_DIRECTORY, + path.posix.basename(absoluteContainerPath) + ); + return { + filePath: resolve(DAIMON_GROK_TURN_USAGE_LEDGER.filePath), + rotatedFilePath: resolve(DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath) + }; +}; + +/** + * Reads exported ledger bytes through the SAME reader the live path uses. + * + * `readUsageLedgerViaExec` is duck-typed on `exec(["cat", file])`, so feeding it + * a file-backed exec reuses the rotation ordering, the parse, and the + * absent-vs-unreadable classification verbatim rather than growing a second + * copy that could drift. A missing file is re-shaped into the failure a real + * `cat` produces — numeric exit code plus "No such file or directory" — because + * that is the exact shape the shared reader recognises as a legitimately absent + * generation. Every other filesystem error keeps its own `code`, which that + * reader treats as UNKNOWN rather than empty. + */ +const exportedLedgerExec: UsageLedgerExec = async (command) => { + const filePath = command[command.length - 1]!; + try { + return { stderr: "", stdout: await readFile(filePath, "utf8") }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + const message = `cat: ${filePath}: No such file or directory`; + throw Object.assign(new Error(message), { code: 1, stderr: message }); + } + throw error; + } +}; + +/** `stat`, not a read: a rotated generation is at least 64 MiB, so probing for + * presence must not pull the bytes in only to throw them away. */ +const fileExists = async (filePath: string): Promise => { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +}; + +/** + * Reads one sealed, exported run instead of a live container. + * + * The roster still comes from the deployment record, exactly as the live path + * resolves it, so the coverage denominator means the same thing in both modes — + * that equivalence is the point, and it is what lets a single aggregation layer + * serve both. + * + * The one semantic that CANNOT be shared is what an absent ledger means. On a + * running container an absent file is genuinely "no turns yet", so the shared + * reader stays silent about it. In an export it means the bytes were never + * captured — a codex-only organization is never provisioned the volume, and an + * export taken before the first metered turn carries nothing — and neither of + * those is evidence that the organization cost nothing. So when the export + * carries no generation at all this reports an explicit unreadable unit, which + * forces coverage PARTIAL and keeps every notional column at `—`, rather than + * rendering a confident `$0.00` over a ledger nobody ever read. + */ +const collectExportedUsage = async ( + exportedDirectory: string, + options: UsageCommandOptions, + outputDirectory: string, + handlers: UsageCommandLiveHandlers +): Promise<(OrganizationUsage & { runId: string }) | { error: string }> => { + const indexPath = path.join(exportedDirectory, "spawnfile", "export-index.json"); + let index; + try { + index = parseExportIndex(JSON.parse(await readFile(indexPath, "utf8"))); + } catch (error) { + return { + error: `Not a Spawnfile export directory: ${exportedDirectory} (${indexPath}: ${ + error instanceof Error ? error.message : String(error) + }). Produce one with \`spawnfile artifacts export --out

\`.` + }; + } + + const list = handlers.listDeploymentRecords ?? listDeploymentRecords; + const selected = selectUsageDeployment( + await list(outputDirectory), + options.deployment ?? index.deployment + ); + if ("error" in selected) return selected; + + const paths = exportedLedgerPaths(exportedDirectory); + const present = await Promise.all([fileExists(paths.filePath), fileExists(paths.rotatedFilePath)]); + if (!present.some(Boolean)) { + return { + deploymentName: selected.name, + records: [], + roster: rosterForRecord(selected), + runId: index.run_id, + unreadableUnits: [{ + containerRef: exportedDirectory, + detail: `${EXPORTED_USAGE_DIRECTORY}/${path.posix.basename(DAIMON_GROK_TURN_USAGE_LEDGER.filePath)} is not in this export`, + reason: "ledger_read_failed", + unitId: `export:${index.run_id}` + }] + }; + } + + const read = await readUsageLedgerViaExec(exportedLedgerExec, paths); + return { + deploymentName: selected.name, + records: read.records, + roster: rosterForRecord(selected), + runId: index.run_id, + unreadableUnits: read.unreadable.map((failure) => ({ + containerRef: exportedDirectory, + detail: `${failure.filePath}: ${failure.reason}`, + reason: "ledger_read_failed" as const, + unitId: `export:${index.run_id}` + })) + }; +}; + +export const executeUsageCommand = async ( + inputPath: string, + options: UsageCommandOptions, + handlers: UsageCommandLiveHandlers = {} +): Promise => { + const since = options.since ?? DEFAULT_USAGE_SINCE; + const sinceMs = parseUsageSinceDuration(since); + if (sinceMs === null) { + return inputFailure(`Invalid --since "${since}". Use a duration like 30m, 24h, or 7d.`); + } + if (options.by !== undefined && options.by !== "agent" && options.by !== "engine") { + return inputFailure(`Invalid --by "${options.by}". Use "agent" or "engine".`); + } + if (options.top !== undefined && !/^[1-9]\d*$/u.test(options.top)) { + return inputFailure(`Invalid --top "${options.top}". Use a positive integer.`); + } + const timeoutMs = options.timeout === undefined ? undefined : Number(options.timeout); + if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { + return inputFailure(`Invalid --timeout "${options.timeout}". Use a positive number of milliseconds.`); + } + + const outputDirectory = resolveProjectOutputDirectory(inputPath, options.out, DEFAULT_OUTPUT_DIRECTORY); + let usage: OrganizationUsage | { error: string }; + try { + // Source selection is explicit, never inferred from what happens to be + // reachable: --exported reads that sealed run and never contacts Docker, + // and without it the live container is read exactly as before. Passing + // --exported alongside a running organization still reads the export -- + // that is the point of naming it -- and the rendered header says which + // source produced the numbers so a reader is never left guessing. + usage = options.exported === undefined + ? await collectOrganizationUsage({ + deployment: options.deployment, + dockerCommand: options.dockerCommand, + outputDirectory, + timeoutMs + }, handlers) + : await collectExportedUsage(options.exported, options, outputDirectory, handlers); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error), exitCode: errorExitCode(error) }; + } + if ("error" in usage) return inputFailure(usage.error); + + const windowed = filterUsageRecordsSince(usage.records, sinceMs) + .filter((record) => options.agent === undefined || record.agent === options.agent); + + if (options.json) { + const coverage = computeUsageCoverage(windowed, usage.roster.length, usage.unreadableUnits.length); + return { + exitCode: 0, + output: `${JSON.stringify({ + version: "spawnfile.usage.v1", + deployment: usage.deploymentName, + source: options.exported === undefined ? "live" : "exported", + since, + lowerBound: true, + coverage, + byAgent: groupUsageByAgent(windowed, usage.roster), + byEngine: groupUsageByEngine(windowed), + unreadableUnits: usage.unreadableUnits + }, null, 2)}` + }; + } + + return { exitCode: 0, output: renderTable(usage, windowed, { ...options, since }) }; +}; + +export const registerUsageCommand = ( + program: Command, + streams: CliStreams, + setExitCode: (exitCode: 0 | 1 | 2) => void, + handlers: UsageCommandLiveHandlers = {} +): void => { + program + .command("usage") + .description("Show what a deployed Spawnfile organization consumed, by agent and by engine") + .argument("[path]", "Project directory or Spawnfile path", process.cwd()) + .option("--out ", "Compile output directory") + .option("--exported ", "Read a sealed run exported by `spawnfile artifacts export --out ` instead of a live container") + .option("--deployment ", "Deployment record name") + .option("--since ", `Window to report, e.g. 30m, 24h, 7d (default ${DEFAULT_USAGE_SINCE})`) + .option("--by ", "Group by \"agent\" (default) or \"engine\"") + .option("--agent ", "Report one agent") + .option("--top ", "Show only the n heaviest agents") + .option("--json", "Render machine-readable JSON") + .option("--docker-command ", "Docker command") + .option("--timeout ", "Bound Docker reads in milliseconds") + .action(async (inputPath: string, options: UsageCommandOptions) => { + const result = await executeUsageCommand(inputPath, options, handlers); + setExitCode(result.exitCode); + if (result.error) streams.stderr(`error: ${result.error}`); + if (result.output) streams.stdout(result.output); + }); +}; diff --git a/src/cli/usageCommandLive.ts b/src/cli/usageCommandLive.ts new file mode 100644 index 00000000..750e30a3 --- /dev/null +++ b/src/cli/usageCommandLive.ts @@ -0,0 +1,173 @@ +import { + createDockerProbeGateway, + inspectDockerDeployment, + listDeploymentRecords, + type DeploymentRecord, + type DockerInspectionResult +} from "../deployment/index.js"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; +import type { UsageRecord, UsageRosterEntry } from "../runtime/usageLedger.js"; +import { + readUsageLedgerViaExec, + type UsageLedgerExec +} from "../runtime/usageLedgerRead.js"; + +/** + * Transport for `spawnfile usage`. + * + * The ledger lives inside the container, and the host may be macOS Docker + * Desktop or a remote Docker context, so a host-side `readFile` is impossible. + * Reads therefore go through the sanctioned channel — the docker probe gateway, + * which already supports `--context` / `--host` remote targets and is already + * used to `cat` container files. `docker exec` runs as the image user (root for + * a Daimon image), so a 0640 uid-2100 ledger is readable. + * + * Deferred: the stopped-container post-mortem path. `spawnfile down` + * deliberately preserves volumes, so a stopped unit's ledger is still on disk, + * but `docker exec` cannot reach it; the repo's `docker create` + `docker cp` + * volume-egress pattern (`artifactsExportDocker.ts`) is the intended fallback. + * Until it lands, a stopped unit is reported as UNREADABLE — never silently as + * zero usage, which would misreport a dead subscription as a cheap one. + */ + +/** + * Why a unit's usage is missing. + * + * `stopped`/`unreachable` are decided before any read is attempted, from the + * container inspection. `ledger_read_failed` is decided by the read itself + * (`readUsageLedgerViaExec`) and carries the generation and bounded reason in + * `detail`: the container was running and reachable, but one of its two ledger + * generations could not be `cat`ed — a `maxBuffer` overrun on a rotated + * generation, a timeout, or a daemon failure. All three mean the same thing + * for reporting: UNKNOWN usage, never zero. + */ +export interface UsageUnitReadFailure { + containerRef: string; + detail?: string; + reason: "ledger_read_failed" | "stopped" | "unreachable"; + unitId: string; +} + +export interface OrganizationUsage { + deploymentName: string; + records: UsageRecord[]; + roster: UsageRosterEntry[]; + unreadableUnits: UsageUnitReadFailure[]; +} + +export interface UsageCommandLiveHandlers { + createDockerProbeGateway?: typeof createDockerProbeGateway; + inspectDockerDeployment?: typeof inspectDockerDeployment; + listDeploymentRecords?: typeof listDeploymentRecords; +} + +export interface CollectOrganizationUsageOptions { + deployment?: string; + dockerCommand?: string; + outputDirectory: string; + timeoutMs?: number; +} + +/** + * The org roster, from the deployment record itself. + * + * Engine assignment is not recorded per agent in a deployment record, so an + * agent's engine is learned from its own ledger records. An agent that never + * reports — every Codex agent, which is uninstrumented — keeps a `null` engine + * and renders as a dashed row. It still counts toward the coverage denominator, + * which is the whole point: a total computed over a partial roster must never be + * presented as the organization's cost. + */ +export const rosterForRecord = (record: DeploymentRecord): UsageRosterEntry[] => { + const agents = new Set(); + for (const unit of record.units) { + for (const entry of unit.contains) { + if (entry.kind === "agent") agents.add(entry.id); + } + } + return [...agents].sort().map((agent) => ({ agent, engine: null })); +}; + +const containerRefForUnit = (unit: DeploymentRecord["units"][number]): string => + unit.container_id ?? unit.container_name ?? unit.id; + +export const selectUsageDeployment = ( + records: Array<{ record: DeploymentRecord }>, + deployment?: string +): DeploymentRecord | { error: string } => { + if (records.length === 0) { + return { error: "No deployment records found. Run `spawnfile up` first." }; + } + if (deployment) { + const match = records.find((entry) => entry.record.name === deployment); + return match + ? match.record + : { error: `Unknown deployment "${deployment}". Valid deployments: ${records.map((entry) => entry.record.name).sort().join(", ")}` }; + } + if (records.length > 1) { + return { error: `spawnfile usage requires --deployment when multiple records exist: ${records.map((entry) => entry.record.name).sort().join(", ")}` }; + } + return records[0]!.record; +}; + +/** + * Read every unit's ledger and merge the results. + * + * A unit that is not running is recorded as unreadable rather than read as + * empty. A running unit whose ledger does not exist yet — the case before the + * first turn ever completes — reads as empty, never as an error, because + * `readUsageLedgerViaExec` treats an absent file (and only an absent file) as + * no content. Any other read failure comes back in that reader's `unreadable` + * list and is recorded here as a `ledger_read_failed` unit, so an unknown + * quantity of turns is never merged into the report as zero. + */ +export const collectOrganizationUsage = async ( + options: CollectOrganizationUsageOptions, + handlers: UsageCommandLiveHandlers = {} +): Promise => { + const list = handlers.listDeploymentRecords ?? listDeploymentRecords; + const inspect = handlers.inspectDockerDeployment ?? inspectDockerDeployment; + const gatewayFor = handlers.createDockerProbeGateway ?? createDockerProbeGateway; + + const selected = selectUsageDeployment(await list(options.outputDirectory), options.deployment); + if ("error" in selected) return selected; + + let inspections: DockerInspectionResult; + try { + inspections = await inspect(selected, { dockerCommand: options.dockerCommand, timeoutMs: options.timeoutMs }); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + + const records: UsageRecord[] = []; + const unreadableUnits: UsageUnitReadFailure[] = []; + for (const unit of selected.units) { + const inspection = inspections.get(unit.id); + if (inspection?.running !== true) { + unreadableUnits.push({ + containerRef: containerRefForUnit(unit), + reason: inspection?.exists === false || inspection?.running === false ? "stopped" : "unreachable", + unitId: unit.id + }); + continue; + } + const gateway = gatewayFor(selected, unit, { + dockerCommand: options.dockerCommand, + inspection, + timeoutMs: options.timeoutMs + }); + const exec: UsageLedgerExec = (command) => gateway.exec(command); + const read = await readUsageLedgerViaExec(exec, DAIMON_GROK_TURN_USAGE_LEDGER); + records.push(...read.records); + for (const failure of read.unreadable) { + unreadableUnits.push({ + containerRef: containerRefForUnit(unit), + detail: `${failure.filePath}: ${failure.reason}`, + reason: "ledger_read_failed", + unitId: unit.id + }); + } + } + + return { deploymentName: selected.name, records, roster: rosterForRecord(selected), unreadableUnits }; +}; diff --git a/src/compiler/containerDaimonBrokerRender.test.ts b/src/compiler/containerDaimonBrokerRender.test.ts index 7e75aa9f..be9e4327 100644 --- a/src/compiler/containerDaimonBrokerRender.test.ts +++ b/src/compiler/containerDaimonBrokerRender.test.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import { renderDaimonBrokerProvisioning, renderDaimonWorkspaceResourceSecurity @@ -28,6 +29,31 @@ describe("Daimon broker registration ABI", () => { }); }); +describe("Daimon broker usage ledger provisioning", () => { + const plan = { + runtimeName: "daimon", + engineByNodeId: { "agent:grok": "grok" }, + instancePaths: { workspacePath: "/workspace" } + } as unknown as Parameters[0][number]; + + it("provisions the usage ledger directory alongside the realm", () => { + const program = renderDaimonBrokerProvisioning([plan]).join("\n"); + const { directoryPath } = DAIMON_GROK_TURN_USAGE_LEDGER; + expect(program).toContain( + `fs.mkdirSync('${directoryPath}', { recursive: true, mode: 0o750 }); fs.chownSync('${directoryPath}', 2100, 2100); fs.chmodSync('${directoryPath}', 0o750);` + ); + }); + + 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}']);` + ); + }); +}); + const validate = async (root: string, resource: Parameters[0][number], linkPath: string, expectedOwners = owners, infoOverride: Record = {}, pathOverrides:Record>={},secondFstatOverride:Record={}) => { const program = [ "const fs=require('node:fs');", diff --git a/src/compiler/containerDaimonBrokerRender.ts b/src/compiler/containerDaimonBrokerRender.ts index 346f8507..56c1689f 100644 --- a/src/compiler/containerDaimonBrokerRender.ts +++ b/src/compiler/containerDaimonBrokerRender.ts @@ -1,6 +1,9 @@ import path from "node:path"; -import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; +import { + DAIMON_GROK_ENGINE_BROKER, + DAIMON_GROK_TURN_USAGE_LEDGER +} from "../runtime/daimon/contractManifest.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; export const DAIMON_ORGANIZATION_UID = 2_000; @@ -81,6 +84,7 @@ export const renderDaimonBrokerProvisioning = (plans: RuntimeTargetPlan[]): stri "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);`, `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; } };`, @@ -89,7 +93,7 @@ export const renderDaimonBrokerProvisioning = (plans: RuntimeTargetPlan[]): stri "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); }`, ...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']);`, + `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); };", diff --git a/src/compiler/containerDaimonUidEntrypointRender.test.ts b/src/compiler/containerDaimonUidEntrypointRender.test.ts index 78645c1a..88f8daa5 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.test.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.test.ts @@ -8,6 +8,7 @@ import { promisify } from "node:util"; 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_AUTHORIZED_UID_ENV, DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS, @@ -363,4 +364,38 @@ describe("renderDaimonUidEntrypoint", () => { expect(rendered).not.toContain("runtime-homes/codex-one/.daimon-inbound/codex-auth"); }); + it("excludes the usage ledger directory from state_roots while keeping it a persistent mount", () => { + const usageDirectory = DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath; + const usageMount = { + id: "daimon-grok-usage-ledger", + mount_path: usageDirectory, + reason: "Daimon per-turn engine usage ledger", + volume_name: "spawnfile-test-grok-usage-ledger" + }; + const ownership = resolveDaimonUidEntrypointOwnershipPlan( + [{ ...daimonPlan, persistentMounts: [usageMount] }], + [usageDirectory] + ); + + // Mutation-critical: deleting the state_roots exclusion for the usage + // ledger directory must turn this assertion red. + expect(ownership.stateRoots).not.toContain(usageDirectory); + + const rendered = renderDaimonUidEntrypoint( + [{ ...daimonPlan, persistentMounts: [usageMount] }], + [usageDirectory] + ); + expect(rendered).not.toContain(`state_roots=('${usageDirectory}')`); + }); + + it("provisions the usage ledger directory and probes it for write access on every boot", () => { + const usageDirectory = DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath; + const rendered = renderDaimonUidEntrypoint([daimonPlan]); + + expect(rendered).toContain(`install -d -o 2100 -g 2100 -m 0750 '${usageDirectory}'`); + 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"'` + ); + }); + }); diff --git a/src/compiler/containerDaimonUidEntrypointRender.ts b/src/compiler/containerDaimonUidEntrypointRender.ts index adcef2fd..55301ae7 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.ts @@ -3,6 +3,7 @@ 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_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import { MOLTNET_READINESS_DIRECTORY } from "./containerReadinessPaths.js"; import { renderDaimonOwnershipProgram, @@ -108,7 +109,9 @@ const writableStateRoots = ( ...resolveDaimonUidEntrypointStateRoots(runtimePlans), ...persistentMountPaths ]) -].filter((root) => root.startsWith("/") && root !== DAIMON_BROKER_REALM).sort(); +].filter((root) => root.startsWith("/") + && root !== DAIMON_BROKER_REALM + && root !== DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath).sort(); const privateDirectoriesThrough = (target: string): string[] => { if ( @@ -300,6 +303,8 @@ export const renderDaimonUidEntrypoint = ( `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`, `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/deployment/dockerProbeGateway.test.ts b/src/deployment/dockerProbeGateway.test.ts index 743dca7b..211c926e 100644 --- a/src/deployment/dockerProbeGateway.test.ts +++ b/src/deployment/dockerProbeGateway.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from "vitest"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; + import type { DeploymentRecord, DockerUnitInspection } from "./index.js"; -import { createDockerProbeGateway } from "./dockerProbeGateway.js"; +import { + createDockerProbeGateway, + DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, + type DockerProbeExecFile +} from "./dockerProbeGateway.js"; const createRecord = (): DeploymentRecord => ({ auth_profile: null, @@ -65,7 +71,7 @@ describe("docker probe gateway", () => { expect(execFile).toHaveBeenCalledWith( "docker", ["--context", "remote", "exec", "container-123", "test", "-d", "/workspace"], - { timeout: 50 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 50 } ); }); @@ -95,7 +101,7 @@ describe("docker probe gateway", () => { 1, "podman", ["--host", "ssh://ops@example", "run", "--rm", "--network", "container:container-123", "--entrypoint", "curl", "image-123", "-sS", "--output", "-", "--write-out", "\\n%{http_code}", "http://127.0.0.1:18789/healthz"], - { timeout: 10000 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 10000 } ); }); @@ -129,7 +135,7 @@ describe("docker probe gateway", () => { expect(execFile).toHaveBeenCalledWith( "docker", ["--context", "legacy", "run", "--rm", "--network", "container:project", "--entrypoint", "curl", "image-123", "-sS", "--output", "-", "--write-out", "\\n%{http_code}", "http://127.0.0.1:18789/healthz"], - { timeout: 10000 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 10000 } ); }); @@ -164,7 +170,7 @@ describe("docker probe gateway", () => { expect(execFile).toHaveBeenCalledWith( "docker", expect.arrayContaining(["sha256:" + "a".repeat(64)]), - { timeout: 10000 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 10000 } ); expect(JSON.stringify(execFile.mock.calls)).not.toContain("super-secret-token"); }); @@ -182,4 +188,106 @@ describe("docker probe gateway", () => { "deployment unit default-container has no recorded container id or name" ); }); + + it("passes a generous default maxBuffer on the exec path, lifting Node's 1 MiB default", async () => { + const record = createRecord(); + const execFile = vi.fn(async () => ({ stderr: "", stdout: "ok\n" })); + const gateway = createDockerProbeGateway(record, record.units[0]!, { + execFile, + inspection + }); + + await gateway.exec(["cat", "/var/lib/spawnfile/daimon/usage/usage.jsonl"]); + + // Mutation check: deleting the maxBuffer plumbing collapses this option object + // back down to `{ timeout }`, which turns this assertion red. + const [, , calledOptions] = execFile.mock.calls[0]!; + expect(calledOptions).toEqual({ maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 10000 }); + expect(calledOptions.maxBuffer).toBeGreaterThan(1024 * 1024); + }); + + it("passes a generous default maxBuffer on the httpGet path too", async () => { + const record = createRecord(); + const execFile = vi.fn(async () => ({ stderr: "", stdout: "ok\n200" })); + const gateway = createDockerProbeGateway(record, record.units[0]!, { + execFile, + inspection + }); + + await gateway.httpGet(8787, "/healthz"); + + const [, , calledOptions] = execFile.mock.calls[0]!; + expect(calledOptions).toEqual({ maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 10000 }); + }); + + it("honors an explicit maxBufferBytes override on the exec path", async () => { + const record = createRecord(); + const execFile = vi.fn(async () => ({ stderr: "", stdout: "ok\n" })); + const gateway = createDockerProbeGateway(record, record.units[0]!, { + execFile, + inspection, + maxBufferBytes: 12_345 + }); + + await gateway.exec(["true"]); + + expect(execFile).toHaveBeenCalledWith( + "docker", + expect.any(Array), + { maxBuffer: 12_345, timeout: 10000 } + ); + }); + + it("round-trips a cat larger than Node's 1 MiB execFile default", async () => { + const record = createRecord(); + // One byte over Node's 1 MiB default maxBuffer — this would previously reject + // with "stdout maxBuffer length exceeded" without the gateway's own maxBuffer. + const largeStdout = `${"x".repeat(1024 * 1024 + 1)}\n`; + const execFile = vi.fn(async (_file: string, _args: string[], options: { maxBuffer?: number; timeout: number }) => { + if (!options.maxBuffer || options.maxBuffer <= largeStdout.length) { + throw new Error("stdout maxBuffer length exceeded"); + } + return { stderr: "", stdout: largeStdout }; + }); + const gateway = createDockerProbeGateway(record, record.units[0]!, { + execFile, + inspection + }); + + await expect(gateway.exec(["cat", "/var/lib/spawnfile/daimon/usage/usage.jsonl"])).resolves.toEqual({ + stderr: "", + stdout: largeStdout + }); + }); + it("defaults maxBuffer to at least twice the ledger rotation bound", () => { + // The gateway `cat`s a ledger generation that rotation guarantees is at + // least TURN_USAGE_ROTATE_BYTES and in practice overshoots it, so a + // maxBuffer merely *equal* to the bound rejects the very read this + // feature exists to perform. + expect(DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES).toBeGreaterThanOrEqual( + 2 * DAIMON_GROK_TURN_USAGE_LEDGER.rotateBytes + ); + }); + + it("round-trips a cat of a rotated ledger generation that overshot the rotation bound", async () => { + const record = createRecord(); + // Rotation fires on the append *after* the file crosses the bound, so the + // rotated generation is always >= the bound and the crossing line + // overshoots it. Node's execFile rejects with + // ERR_CHILD_PROCESS_STDIO_MAXBUFFER once stdout exceeds maxBuffer. + const overshotStdout = "x".repeat(DAIMON_GROK_TURN_USAGE_LEDGER.rotateBytes + 100); + const execFile = vi.fn(async (_file: string, _args: string[], options: { maxBuffer?: number; timeout: number }) => { + const limit = options.maxBuffer ?? 1024 * 1024; + if (Buffer.byteLength(overshotStdout, "utf8") > limit) { + throw Object.assign(new Error("stdout maxBuffer length exceeded"), { + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" + }); + } + return { stderr: "", stdout: overshotStdout }; + }); + const gateway = createDockerProbeGateway(record, record.units[0]!, { execFile, inspection }); + + const result = await gateway.exec(["cat", DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath]); + expect(result.stdout.length).toBe(overshotStdout.length); + }); }); diff --git a/src/deployment/dockerProbeGateway.ts b/src/deployment/dockerProbeGateway.ts index fadd76ec..1933cc9a 100644 --- a/src/deployment/dockerProbeGateway.ts +++ b/src/deployment/dockerProbeGateway.ts @@ -1,6 +1,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import type { RuntimeProbeExecResult, RuntimeProbeGateway, @@ -13,16 +14,34 @@ import { dockerContextNameForTarget } from "./target.js"; const execFile = promisify(execFileCallback); +/** + * The ledger this gateway reads (`spawnfile usage`) rotates once a generation + * reaches `DAIMON_GROK_TURN_USAGE_LEDGER.rotateBytes`, and rotation happens on + * the append *after* that threshold is crossed — so a rotated generation is + * always at least the bound and the crossing line pushes it over. A maxBuffer + * merely equal to the bound therefore rejects the exact read this feature + * exists to perform (Node's `execFile` accepts a payload of exactly + * `maxBuffer` bytes and fails the next one with + * `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`). Doubling the bound leaves a full + * generation of headroom, and is derived from the ledger constant rather than + * restated so the two cannot drift apart. + */ +export const DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES = + 2 * DAIMON_GROK_TURN_USAGE_LEDGER.rotateBytes; + export type DockerProbeExecFile = ( file: string, args: string[], - options: { timeout: number } + options: { maxBuffer?: number; timeout: number } ) => Promise<{ stderr: string; stdout: string }>; export interface DockerProbeGatewayOptions { dockerCommand?: string; execFile?: DockerProbeExecFile; inspection: DockerUnitInspection; + /** Overrides Node's 1 MiB `execFile` default `maxBuffer`. Defaults to + * {@link DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES}. */ + maxBufferBytes?: number; timeoutMs?: number; } @@ -121,13 +140,14 @@ export const createDockerProbeGateway = ( const dockerCommand = options.dockerCommand ?? "docker"; const runExec = options.execFile ?? execFile; const timeout = options.timeoutMs ?? 10_000; + const maxBuffer = options.maxBufferBytes ?? DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES; const exec = async (command: string[]): Promise => { const targetRef = targetRefForUnit(unit); return runExec( dockerCommand, withDockerTarget(record, ["exec", targetRef, ...command]), - { timeout } + { maxBuffer, timeout } ); }; @@ -147,7 +167,7 @@ export const createDockerProbeGateway = ( const result = await runExec( dockerCommand, withDockerTarget(record, httpProbeArgs(targetRef, imageRefForUnit(unit), url)), - { timeout } + { maxBuffer, timeout } ); return parseCurlHttpOutput(result.stdout); } catch (error) { diff --git a/src/runtime/usageLedger.test.ts b/src/runtime/usageLedger.test.ts new file mode 100644 index 00000000..83cdcbef --- /dev/null +++ b/src/runtime/usageLedger.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; + +import { + computeUsageCoverage, + DEFAULT_USAGE_SINCE, + filterUsageRecordsSince, + groupUsageByAgent, + groupUsageByEngine, + parseUsageLedger, + parseUsageLedgerLine, + parseUsageSinceDuration, + USAGE_TURN_RECORD_VERSION, + type UsageRecord +} from "./usageLedger.js"; + +const record = (overrides: Partial = {}): UsageRecord => ({ + agent: "cogsworth", + at: "2026-08-29T01:12:04.000Z", + cache_read: 5760, + cache_write: 0, + calls: 1, + complete: true, + engine: "grok", + input: 8746, + notional_usd: 0.0035, + output: 29, + total: 14535, + v: USAGE_TURN_RECORD_VERSION, + wake: "wake-1", + ...overrides +}); + +const line = (overrides: Partial = {}): string => JSON.stringify(record(overrides)); + +describe("parseUsageLedgerLine", () => { + it("parses a well-formed line", () => { + expect(parseUsageLedgerLine(line())).toEqual(record()); + }); + + it("returns null for a blank line", () => { + expect(parseUsageLedgerLine("")).toBeNull(); + expect(parseUsageLedgerLine(" ")).toBeNull(); + }); + + it("rejects a wrong-version line without throwing", () => { + const malformed = JSON.stringify({ ...record(), v: "noopolis.daimon.turn-usage.v2" }); + expect(parseUsageLedgerLine(malformed)).toBeNull(); + }); + + it("rejects a line with a negative or non-finite numeric field", () => { + expect(parseUsageLedgerLine(line({ input: -1 }))).toBeNull(); + expect(parseUsageLedgerLine(JSON.stringify({ ...record(), total: Number.NaN }))).toBeNull(); + expect(parseUsageLedgerLine(JSON.stringify({ ...record(), notional_usd: Infinity }))).toBeNull(); + }); + + it("rejects a line with a stringified numeric field", () => { + expect(parseUsageLedgerLine(JSON.stringify({ ...record(), calls: "1" }))).toBeNull(); + }); + + it("rejects a line missing a required string field", () => { + const { agent: _agent, ...withoutAgent } = record(); + expect(parseUsageLedgerLine(JSON.stringify(withoutAgent))).toBeNull(); + }); + + it("rejects a line with an unparseable `at`", () => { + expect(parseUsageLedgerLine(line({ at: "not-a-date" }))).toBeNull(); + }); + + it("rejects a non-object JSON value without throwing", () => { + expect(parseUsageLedgerLine("42")).toBeNull(); + expect(parseUsageLedgerLine("[1,2,3]")).toBeNull(); + expect(parseUsageLedgerLine("null")).toBeNull(); + }); + + it("never throws on garbage input", () => { + expect(() => parseUsageLedgerLine("{not json")).not.toThrow(); + expect(parseUsageLedgerLine("{not json")).toBeNull(); + }); +}); + +describe("parseUsageLedger", () => { + it("skips an unterminated trailing line (a crash mid-append) and keeps the rest", () => { + const goodLine = line({ agent: "cogsworth" }); + // A torn record: append truncated mid-object, exactly as a crash mid-write would leave it. + const tornTail = '{"v":"noopolis.daimon.turn-usage.v1","agent":"foreman","wake":"wake-2","eng'; + const text = `${goodLine}\n${tornTail}`; + + const records = parseUsageLedger(text); + + expect(records).toHaveLength(1); + expect(records[0]!.agent).toBe("cogsworth"); + }); + + it("skips a malformed/wrong-version line but keeps parsing the rest of the file", () => { + const text = [ + line({ agent: "cogsworth" }), + JSON.stringify({ ...record(), v: "noopolis.daimon.turn-usage.v0" }), + "not even json", + line({ agent: "foreman" }) + ].join("\n"); + + const records = parseUsageLedger(text); + + expect(records.map((r) => r.agent)).toEqual(["cogsworth", "foreman"]); + }); + + it("returns an empty array for empty text", () => { + expect(parseUsageLedger("")).toEqual([]); + }); + + it("ignores blank lines between records", () => { + const text = `${line({ agent: "cogsworth" })}\n\n${line({ agent: "foreman" })}\n`; + expect(parseUsageLedger(text).map((r) => r.agent)).toEqual(["cogsworth", "foreman"]); + }); +}); + +describe("parseUsageSinceDuration", () => { + it("parses hours, days, and minutes", () => { + expect(parseUsageSinceDuration("24h")).toBe(24 * 60 * 60 * 1000); + expect(parseUsageSinceDuration("7d")).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseUsageSinceDuration("30m")).toBe(30 * 60 * 1000); + }); + + it("returns null for an unrecognized format", () => { + expect(parseUsageSinceDuration("yesterday")).toBeNull(); + expect(parseUsageSinceDuration("24")).toBeNull(); + expect(parseUsageSinceDuration("24w")).toBeNull(); + }); + + it("has a default matching the design's org-total default window", () => { + expect(DEFAULT_USAGE_SINCE).toBe("24h"); + }); +}); + +describe("filterUsageRecordsSince", () => { + const now = Date.parse("2026-08-29T12:00:00.000Z"); + + it("keeps records at or after the cutoff and drops older ones", () => { + const inWindow = record({ at: "2026-08-29T06:00:00.000Z" }); // 6h ago + const outOfWindow = record({ at: "2026-08-27T00:00:00.000Z" }); // days ago + const records = filterUsageRecordsSince([inWindow, outOfWindow], 24 * 60 * 60 * 1000, now); + expect(records).toEqual([inWindow]); + }); + + it("a window spanning a rotation keeps records from both generations", () => { + // Simulates records merged from usage.jsonl.1 (rotated, older) and usage.jsonl (current). + const rotated = record({ agent: "cogsworth", at: "2026-08-28T13:00:00.000Z" }); // 23h ago + const current = record({ agent: "cogsworth", at: "2026-08-29T11:00:00.000Z" }); // 1h ago + const records = filterUsageRecordsSince([rotated, current], 24 * 60 * 60 * 1000, now); + expect(records).toHaveLength(2); + }); +}); + +describe("groupUsageByAgent", () => { + it("sums turns, tokens, notional, and incomplete count per agent", () => { + const records = [ + record({ agent: "cogsworth", complete: true, notional_usd: 1, total: 100 }), + record({ agent: "cogsworth", complete: false, notional_usd: 2, total: 200 }), + record({ agent: "foreman", engine: "grok", notional_usd: 3, total: 300 }) + ]; + + const groups = groupUsageByAgent(records); + const cogsworth = groups.find((g) => g.agent === "cogsworth")!; + expect(cogsworth).toEqual({ + agent: "cogsworth", + engine: "grok", + incompleteTurns: 1, + notionalUsd: 3, + tokens: 300, + turns: 2 + }); + expect(groups.find((g) => g.agent === "foreman")).toEqual({ + agent: "foreman", + engine: "grok", + incompleteTurns: 0, + notionalUsd: 3, + tokens: 300, + turns: 1 + }); + }); + + it("seeds a zero-usage row for a roster agent with no records (uninstrumented engine)", () => { + const records = [record({ agent: "cogsworth" })]; + const groups = groupUsageByAgent(records, [ + { agent: "cogsworth", engine: "grok" }, + { agent: "brass", engine: "codex" } + ]); + + expect(groups.find((g) => g.agent === "brass")).toEqual({ + agent: "brass", + engine: "codex", + incompleteTurns: 0, + notionalUsd: 0, + tokens: 0, + turns: 0 + }); + }); +}); + +describe("groupUsageByEngine", () => { + it("sums per engine and seeds a zero-usage row for a known engine with no records", () => { + const records = [ + record({ engine: "grok", notional_usd: 6.8, total: 2_100_000 }), + record({ agent: "foreman", engine: "grok", notional_usd: 4.3, total: 1_400_000 }) + ]; + + const groups = groupUsageByEngine(records, ["grok", "codex"]); + + expect(groups.find((g) => g.engine === "grok")).toEqual({ + engine: "grok", + incompleteTurns: 0, + notionalUsd: 11.1, + tokens: 3_500_000, + turns: 2 + }); + expect(groups.find((g) => g.engine === "codex")).toEqual({ + engine: "codex", + incompleteTurns: 0, + notionalUsd: 0, + tokens: 0, + turns: 0 + }); + }); +}); + +describe("computeUsageCoverage", () => { + it("is PARTIAL when an engine (and its agents) report nothing", () => { + const records = [record({ agent: "cogsworth" }), record({ agent: "foreman" })]; + const coverage = computeUsageCoverage(records, 16); + + expect(coverage).toEqual({ + agentsReporting: 2, + agentsTotal: 16, + incompleteRecordCount: 0, + partial: true, + unreadableUnitCount: 0 + }); + }); + + it("is not PARTIAL when every roster agent reported", () => { + const records = [record({ agent: "cogsworth" }), record({ agent: "foreman" })]; + const coverage = computeUsageCoverage(records, 2); + expect(coverage.partial).toBe(false); + }); + + it("is PARTIAL when a ledger could not be read at all, even with a full roster", () => { + // Zero must be distinguishable from unknown: a unit whose ledger read + // failed contributes no records, so a full-roster window would otherwise + // be presented as the organization's complete cost. + const records = [record({ agent: "cogsworth" }), record({ agent: "foreman" })]; + const coverage = computeUsageCoverage(records, 2, 1); + expect(coverage.unreadableUnitCount).toBe(1); + expect(coverage.partial).toBe(true); + }); + + it("counts complete:false records as a lower-bound signal", () => { + const records = [ + record({ agent: "cogsworth", complete: true }), + record({ agent: "cogsworth", complete: false }), + record({ agent: "foreman", complete: false }) + ]; + expect(computeUsageCoverage(records, 2).incompleteRecordCount).toBe(2); + }); +}); diff --git a/src/runtime/usageLedger.ts b/src/runtime/usageLedger.ts new file mode 100644 index 00000000..cc58ccde --- /dev/null +++ b/src/runtime/usageLedger.ts @@ -0,0 +1,302 @@ +/** + * Pure reader/aggregator for Daimon's per-turn usage ledger + * (`noopolis.daimon.turn-usage.v1`, see `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 + * volume egress, and where the ledger actually lives) is a CLI-layer concern + * in `src/cli/usageCommandLive.ts`, which composes this module with + * `src/deployment`'s docker probe gateway and volume-egress helpers. Keeping + * that composition out of this folder avoids a value-level import cycle: + * `src/deployment` already imports real (non-type) exports from this folder + * (`dockerManager.ts`'s `resolveNoopolisRunId`), so this folder must not + * import real exports back from `src/deployment`. + * + * The read transport itself (`readUsageLedgerViaExec`, and the rule for when + * a failed `cat` means "empty" versus "unknown") lives beside this file in + * `usageLedgerRead.ts`, so this module performs no I/O at all. + */ + +export const USAGE_TURN_RECORD_VERSION = "noopolis.daimon.turn-usage.v1" as const; + +/** One parsed, validated line from the usage ledger. */ +export interface UsageRecord { + agent: string; + at: string; + cache_read: number; + cache_write: number; + calls: number; + complete: boolean; + engine: string; + input: number; + notional_usd: number; + output: number; + total: number; + v: typeof USAGE_TURN_RECORD_VERSION; + wake: string; +} + +const NUMERIC_FIELDS = [ + "input", + "output", + "cache_read", + "cache_write", + "total", + "calls", + "notional_usd" +] as const; + +const isFiniteNonNegative = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +/** + * Parses one ledger line. Returns `null` (never throws) for: blank lines, + * JSON that fails to parse (this is also how a torn trailing line left by a + * crash mid-append is skipped — a truncated JSON object fails to parse the + * same way a garbled line would), a schema version other than + * `noopolis.daimon.turn-usage.v1`, a missing/empty string field, an `at` that + * doesn't parse as a date, a non-boolean `complete`, or any numeric field + * that isn't a finite, non-negative number. + */ +export const parseUsageLedgerLine = (line: string): UsageRecord | null => { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + const record = parsed as Record; + + if (record.v !== USAGE_TURN_RECORD_VERSION) { + return null; + } + if ( + !isNonEmptyString(record.agent) + || !isNonEmptyString(record.wake) + || !isNonEmptyString(record.engine) + || !isNonEmptyString(record.at) + ) { + return null; + } + if (Number.isNaN(Date.parse(record.at))) { + return null; + } + if (typeof record.complete !== "boolean") { + return null; + } + for (const field of NUMERIC_FIELDS) { + if (!isFiniteNonNegative(record[field])) { + return null; + } + } + + return { + agent: record.agent, + at: record.at, + cache_read: record.cache_read as number, + cache_write: record.cache_write as number, + calls: record.calls as number, + complete: record.complete, + engine: record.engine, + input: record.input as number, + notional_usd: record.notional_usd as number, + output: record.output as number, + total: record.total as number, + v: USAGE_TURN_RECORD_VERSION, + wake: record.wake + }; +}; + +/** + * Parses a newline-delimited ledger file. Never throws: an unparseable line — + * including an unterminated trailing line left by a crash mid-append — is + * skipped, and the rest of the file still parses. + */ +export const parseUsageLedger = (text: string): UsageRecord[] => + text + .split("\n") + .map(parseUsageLedgerLine) + .filter((record): record is UsageRecord => record !== null); + +const DURATION_UNIT_MS: Record = { + d: 24 * 60 * 60 * 1000, + h: 60 * 60 * 1000, + m: 60 * 1000 +}; + +/** The default window `spawnfile usage` uses when `--since` is omitted. */ +export const DEFAULT_USAGE_SINCE = "24h"; + +/** Parses a duration like `"24h"`, `"7d"`, or `"30m"` into milliseconds. + * Returns `null` for anything else — the caller is responsible for turning + * that into a user-facing input error. */ +export const parseUsageSinceDuration = (input: string): number | null => { + const match = /^(\d+)(h|d|m)$/u.exec(input.trim()); + if (!match) { + return null; + } + const amount = Number(match[1]); + const unit = match[2]!; + return amount * DURATION_UNIT_MS[unit]!; +}; + +/** Keeps only records at or after `now - sinceMs`. `now` defaults to the real + * clock; tests pass a fixed value. */ +export const filterUsageRecordsSince = ( + records: UsageRecord[], + sinceMs: number, + now: number = Date.now() +): UsageRecord[] => { + const cutoff = now - sinceMs; + return records.filter((record) => Date.parse(record.at) >= cutoff); +}; + +/** A roster entry: one agent the org compiled, and the engine it was + * assigned — independent of whether it ever produced a usage record (a + * Codex-engine agent is uninstrumented and will never appear in the ledger, + * but still belongs in the roster so coverage/grouping can show it with + * zeroes rather than silently omitting it). */ +export interface UsageRosterEntry { + agent: string; + /** `null` when the roster source cannot name the agent's engine — a + * deployment record lists agents but not engine assignment, so the engine is + * learned from the agent's own ledger records and stays `null` for an agent + * that never reports. */ + engine: string | null; +} + +export interface UsageAgentGroup { + agent: string; + engine: string | null; + incompleteTurns: number; + notionalUsd: number; + tokens: number; + turns: number; +} + +const emptyAgentGroup = (agent: string, engine: string | null): UsageAgentGroup => ({ + agent, + engine, + incompleteTurns: 0, + notionalUsd: 0, + tokens: 0, + turns: 0 +}); + +/** Groups records by agent. `roster`, when supplied, seeds a zero-usage row + * for every known agent (so an uninstrumented engine's agents still show up + * with dashes instead of vanishing from the table). */ +export const groupUsageByAgent = ( + records: UsageRecord[], + roster: UsageRosterEntry[] = [] +): UsageAgentGroup[] => { + const byAgent = new Map(); + for (const entry of roster) { + byAgent.set(entry.agent, emptyAgentGroup(entry.agent, entry.engine)); + } + for (const record of records) { + const existing = byAgent.get(record.agent) ?? emptyAgentGroup(record.agent, record.engine); + existing.turns += 1; + existing.tokens += record.total; + existing.notionalUsd += record.notional_usd; + if (!record.complete) { + existing.incompleteTurns += 1; + } + if (existing.engine === null) { + existing.engine = record.engine; + } + byAgent.set(record.agent, existing); + } + return [...byAgent.values()]; +}; + +export interface UsageEngineGroup { + engine: string; + incompleteTurns: number; + notionalUsd: number; + tokens: number; + turns: number; +} + +const emptyEngineGroup = (engine: string): UsageEngineGroup => ({ + engine, + incompleteTurns: 0, + notionalUsd: 0, + tokens: 0, + turns: 0 +}); + +/** Groups records by engine. `engines`, when supplied, seeds a zero-usage row + * for every known engine (so Codex — uninstrumented — shows up as a dashed + * row rather than being absent from the rollup). */ +export const groupUsageByEngine = ( + records: UsageRecord[], + engines: string[] = [] +): UsageEngineGroup[] => { + const byEngine = new Map(); + for (const engine of engines) { + byEngine.set(engine, emptyEngineGroup(engine)); + } + for (const record of records) { + const existing = byEngine.get(record.engine) ?? emptyEngineGroup(record.engine); + existing.turns += 1; + existing.tokens += record.total; + existing.notionalUsd += record.notional_usd; + if (!record.complete) { + existing.incompleteTurns += 1; + } + byEngine.set(record.engine, existing); + } + return [...byEngine.values()]; +}; + +export interface UsageCoverage { + agentsReporting: number; + agentsTotal: number; + /** Count of `complete:false` records in the window — every count here is a + * lower bound regardless of this number (see module doc / design + * "Verification" — grok's `streaming-messages-json` carries no + * completeness marker for a partially zero-filled turn), but a nonzero + * count is at least a partial, observable signal of it. */ + incompleteRecordCount: number; + /** True when at least one roster agent produced zero records in the + * window, or when at least one ledger could not be read — the resulting + * total must be labelled PARTIAL and never presented as the org's full + * cost. */ + partial: boolean; + /** Ledgers (units or rotated generations) whose read failed outright. Zero + * records from an unreadable ledger is UNKNOWN usage, not free usage, so a + * nonzero count here forces `partial` regardless of the roster. */ + unreadableUnitCount: number; +} + +/** Coverage is computed against `totalAgents` (the full org roster size), not + * against however many distinct agents happen to appear in `records` — an + * uninstrumented engine's agents must count toward the shortfall, not + * disappear from the denominator. */ +export const computeUsageCoverage = ( + records: UsageRecord[], + totalAgents: number, + unreadableUnitCount = 0 +): UsageCoverage => { + const reporting = new Set(records.map((record) => record.agent)).size; + return { + agentsReporting: reporting, + agentsTotal: totalAgents, + incompleteRecordCount: records.filter((record) => !record.complete).length, + partial: reporting < totalAgents || unreadableUnitCount > 0, + unreadableUnitCount + }; +}; diff --git a/src/runtime/usageLedgerRead.test.ts b/src/runtime/usageLedgerRead.test.ts new file mode 100644 index 00000000..a97600f3 --- /dev/null +++ b/src/runtime/usageLedgerRead.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { USAGE_TURN_RECORD_VERSION, type UsageRecord } from "./usageLedger.js"; +import { readUsageLedgerViaExec } from "./usageLedgerRead.js"; + +const record = (overrides: Partial = {}): UsageRecord => ({ + agent: "cogsworth", + at: "2026-08-29T01:12:04.000Z", + cache_read: 5760, + cache_write: 0, + calls: 1, + complete: true, + engine: "grok", + input: 8746, + notional_usd: 0.0035, + output: 29, + total: 14535, + v: USAGE_TURN_RECORD_VERSION, + wake: "wake-1", + ...overrides +}); + +const line = (overrides: Partial = {}): string => JSON.stringify(record(overrides)); + +describe("readUsageLedgerViaExec", () => { + const paths = { + filePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl", + rotatedFilePath: "/var/lib/spawnfile/daimon/usage/usage.jsonl.1" + }; + + it("merges both generations, rotated (older) first", async () => { + const exec = async (command: string[]) => { + const target = command[1]; + if (target === paths.rotatedFilePath) { + return { stderr: "", stdout: `${line({ agent: "rotated-agent" })}\n` }; + } + if (target === paths.filePath) { + return { stderr: "", stdout: `${line({ agent: "current-agent" })}\n` }; + } + throw new Error(`unexpected cat target: ${target}`); + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.records.map((r) => r.agent)).toEqual(["rotated-agent", "current-agent"]); + expect(read.unreadable).toEqual([]); + }); + + it("renders a missing ledger as EMPTY, never an error (ENOENT before the first turn)", async () => { + const exec = async () => { + throw Object.assign(new Error("cat: /var/lib/spawnfile/daimon/usage/usage.jsonl: No such file or directory"), { + code: 1 + }); + }; + + await expect(readUsageLedgerViaExec(exec, paths)).resolves.toEqual({ records: [], unreadable: [] }); + }); + + it("treats a missing rotated generation as empty while still reading the current one", async () => { + const exec = async (command: string[]) => { + if (command[1] === paths.rotatedFilePath) { + throw new Error("No such file or directory"); + } + return { stderr: "", stdout: `${line({ agent: "cogsworth" })}\n` }; + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.records.map((r) => r.agent)).toEqual(["cogsworth"]); + expect(read.unreadable).toEqual([]); + }); + + it("reports a rotated generation that overran maxBuffer as UNREADABLE, never as empty", async () => { + // The exact failure `spawnfile usage` must never swallow: a rotated + // generation larger than the read buffer. Reading it as "" silently + // deletes a whole generation of turns from the report. + const exec = async (command: string[]) => { + if (command[1] === paths.rotatedFilePath) { + throw Object.assign(new Error("stdout maxBuffer length exceeded"), { + code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" + }); + } + return { stderr: "", stdout: `${line({ agent: "cogsworth" })}\n` }; + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.records.map((r) => r.agent)).toEqual(["cogsworth"]); + expect(read.unreadable).toEqual([ + { filePath: paths.rotatedFilePath, reason: "stdout maxBuffer length exceeded" } + ]); + }); + + it("reports a timed-out read as UNREADABLE, never as empty", async () => { + const exec = async () => { + throw Object.assign(new Error("Command failed: docker exec container-0 cat usage.jsonl"), { + killed: true, + signal: "SIGTERM", + stderr: "" + }); + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.records).toEqual([]); + expect(read.unreadable.map((entry) => entry.filePath).sort()).toEqual([ + paths.filePath, + paths.rotatedFilePath + ]); + }); + + it("reports a daemon failure as UNREADABLE, never as empty", async () => { + const exec = async () => { + throw Object.assign(new Error("Command failed"), { + code: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock." + }); + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.records).toEqual([]); + expect(read.unreadable).toHaveLength(2); + expect(read.unreadable[0]!.reason).toContain("Cannot connect to the Docker daemon"); + }); + + it("bounds and redacts the failure reason it surfaces", async () => { + const exec = async () => { + throw Object.assign(new Error("Command failed"), { + code: 1, + stderr: `denied Authorization: Bearer super-secret-token ${"y".repeat(600)}` + }); + }; + + const read = await readUsageLedgerViaExec(exec, paths); + expect(read.unreadable[0]!.reason).not.toContain("super-secret-token"); + expect(read.unreadable[0]!.reason.length).toBeLessThanOrEqual(240); + }); +}); diff --git a/src/runtime/usageLedgerRead.ts b/src/runtime/usageLedgerRead.ts new file mode 100644 index 00000000..c9ceb0a4 --- /dev/null +++ b/src/runtime/usageLedgerRead.ts @@ -0,0 +1,139 @@ +/** + * Transport half of Daimon's per-turn usage ledger reader + * (`noopolis.daimon.turn-usage.v1`, see `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 + * means "empty" or "unknown". + * + * `exec` is duck-typed (structurally the same shape as + * `RuntimeProbeGateway.exec` in `./types.ts`) rather than a concrete gateway, + * so this module never depends on `src/deployment` — which already imports + * real exports from this folder, and so must not be imported back. + */ + +import { parseUsageLedger, type UsageRecord } from "./usageLedger.js"; + +/** The exec shape this module needs to read a ledger — structurally the same + * as `RuntimeProbeGateway.exec` (`./types.ts`), duck-typed here rather than + * imported so this module never depends on a concrete gateway construction + * path. */ +export type UsageLedgerExec = ( + command: string[] +) => Promise<{ stderr: string; stdout: string }>; + +export interface UsageLedgerPaths { + filePath: string; + rotatedFilePath: string; +} + +/** One ledger generation that exists but could not be read. Never merged into + * the record stream and never rendered as zero usage: the caller must surface + * it so an unknown window is not mistaken for a cheap one. */ +export interface UsageLedgerReadFailure { + filePath: string; + reason: string; +} + +export interface UsageLedgerRead { + records: UsageRecord[]; + unreadable: UsageLedgerReadFailure[]; +} + +/** Same redaction discipline as the docker probe gateway's own failure + * summaries — written here rather than imported because this module must not + * depend on `src/deployment` (see the module doc). */ +const boundedReadFailure = (error: unknown): string => { + const text = ((): string => { + if (error && typeof error === "object") { + const candidate = error as { message?: unknown; stderr?: unknown }; + if (typeof candidate.stderr === "string" && candidate.stderr.trim().length > 0) { + return candidate.stderr; + } + if (typeof candidate.message === "string") return candidate.message; + } + return String(error); + })(); + return text + .replace(/\s+/gu, " ") + .replace(/(bearer|token|password|passwd|secret|authorization)[=: ]+(?:bearer[ ]+)?[^ ]+/giu, "$1=[redacted]") + .replace(/https?:\/\/[^ ]+/giu, "[url redacted]") + .trim() + .slice(0, 240) || "ledger read failed"; +}; + +/** + * True only for the one failure that legitimately means "there is nothing + * here": the file does not exist. That is the normal state of + * `usage.jsonl.1` before the first rotation, and of `usage.jsonl` before the + * first turn ever completes, so it must stay silent. + * + * Everything else — a `maxBuffer` overrun on a rotated generation, the read + * timing out, the daemon being unreachable, the container having gone away — + * is an UNKNOWN number of turns, and reporting it as zero is exactly the + * silent data loss `spawnfile usage` exists to prevent. A killed or + * string-coded failure is never read as absence no matter what text it + * carries, because a truncated or aborted read can still have emitted + * unrelated stderr. + */ +const isAbsentLedgerFile = (error: unknown): boolean => { + if (!error || typeof error !== "object") return false; + const candidate = error as { + code?: unknown; + killed?: unknown; + message?: unknown; + signal?: unknown; + stderr?: unknown; + }; + if (candidate.killed === true || (candidate.signal !== undefined && candidate.signal !== null)) { + return false; + } + // Node reports spawn/stream failures with a string `code` + // (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`, or `ENOENT` for a missing docker + // binary); a plain non-zero `cat` exit carries a numeric one. + if (typeof candidate.code === "string") return false; + const stderr = typeof candidate.stderr === "string" ? candidate.stderr : ""; + const message = typeof candidate.message === "string" ? candidate.message : ""; + return /no such file or directory/iu.test(`${stderr}\n${message}`); +}; + +/** `cat`s one generation through `exec`. A genuinely absent file reads as + * empty; every other failure is reported, never swallowed. */ +const readLedgerGeneration = async ( + exec: UsageLedgerExec, + filePath: string +): Promise<{ failure?: UsageLedgerReadFailure; text: string }> => { + try { + const result = await exec(["cat", filePath]); + return { text: result.stdout }; + } catch (error) { + if (isAbsentLedgerFile(error)) return { text: "" }; + return { failure: { filePath, reason: boundedReadFailure(error) }, text: "" }; + } +}; + +/** + * Reads both ledger generations (`usage.jsonl` and `usage.jsonl.1`) through + * `exec` and parses them. Rotated (older) records are returned before + * current ones so a `--since` window spanning a rotation reads in + * chronological order. + * + * Generations that could not be read come back in `unreadable` rather than as + * missing records, so the caller can render an UNREADABLE row and mark the + * window partial instead of reporting an unknown amount of usage as zero. + */ +export const readUsageLedgerViaExec = async ( + exec: UsageLedgerExec, + paths: UsageLedgerPaths +): Promise => { + const [rotated, primary] = await Promise.all([ + readLedgerGeneration(exec, paths.rotatedFilePath), + readLedgerGeneration(exec, paths.filePath) + ]); + return { + records: [...parseUsageLedger(rotated.text), ...parseUsageLedger(primary.text)], + unreadable: [rotated.failure, primary.failure].filter( + (failure): failure is UsageLedgerReadFailure => failure !== undefined + ) + }; +}; diff --git a/src/status/runtimeProbes.test.ts b/src/status/runtimeProbes.test.ts index 5c8288e2..3d834774 100644 --- a/src/status/runtimeProbes.test.ts +++ b/src/status/runtimeProbes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES } from "../deployment/index.js"; import type { DeploymentRecord, DockerInspectionResult } from "../deployment/index.js"; import { openClawAdapter } from "../runtime/openclaw/adapter.js"; import type { LoadedCompileReport } from "./compileReport.js"; @@ -114,7 +115,7 @@ describe("runtime probe collection", () => { "\\n%{http_code}", "http://127.0.0.1:18789/healthz" ], - { timeout: 25 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 25 } ); }); @@ -159,7 +160,7 @@ describe("runtime probe collection", () => { expect(execFile).toHaveBeenCalledWith( "docker", ["--host", "ssh://ops@example", "exec", "container-123", "cat", "/instances/agent-analyst/workspace/cron/jobs.json"], - { timeout: 25 } + { maxBuffer: DEFAULT_DOCKER_PROBE_MAX_BUFFER_BYTES, timeout: 25 } ); }); From c49685efa092b03a80d6eb3ddcefbf20d7462505 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:25 +0200 Subject: [PATCH 21/34] feat(export): carry both usage ledger generations into exported run artifacts --- src/deployment/artifactsExportPlan.test.ts | 64 ++++++++++++++++++++++ src/deployment/artifactsExportPlan.ts | 57 ++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/deployment/artifactsExportPlan.test.ts b/src/deployment/artifactsExportPlan.test.ts index f2629060..7e14be81 100644 --- a/src/deployment/artifactsExportPlan.test.ts +++ b/src/deployment/artifactsExportPlan.test.ts @@ -311,4 +311,68 @@ describe("planArtifactExports", () => { it("returns an empty plan for a report with no container section", () => { expect(planArtifactExports({ diagnostics: [], nodes: [], root: "/project", spawnfile_version: "0.1" })).toEqual([]); }); + + /** + * Cost has to survive teardown. `spawnfile usage` reads the ledger live via + * `docker exec`, which a sealed, torn-down run does not have, so unless export + * carries the volume the numbers are gone exactly when someone wants them. + */ + it("plans both usage ledger generations when the daimon usage volume is mounted", () => { + const report = baseReport(baseContainer({ + persistent_mounts: [ + { + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger", + volume_name: "spawnfile-project-daimon-grok-usage-ledger-abc123" + } + ] + })); + + const planned = planArtifactExports(report); + + expect(planned).toEqual([ + { + optional: true, + relativePath: "raw/daimon/usage.jsonl", + source: { + kind: "volume", + volumeName: "spawnfile-project-daimon-grok-usage-ledger-abc123", + volumePath: "usage.jsonl" + } + }, + { + // The rotated generation is not optional-because-unimportant: a deployment + // long enough to rotate keeps its earlier turns ONLY here, so omitting it + // silently truncates history for the most expensive runs. + optional: true, + relativePath: "raw/daimon/usage.jsonl.1", + source: { + kind: "volume", + volumeName: "spawnfile-project-daimon-grok-usage-ledger-abc123", + volumePath: "usage.jsonl.1" + } + } + ]); + }); + + it("plans no usage ledger for an organization that was never provisioned the volume", () => { + // A codex-only org writes no ledger at all and is given no usage mount. Absent + // must stay absent: nothing planned, rather than an entry that would export as + // an empty file and read as a genuine zero-cost run. + const report = baseReport(baseContainer({ + persistent_mounts: [ + { + id: "daimon-agy-subscription-realm", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", + reason: "AGY subscription realm", + volume_name: "spawnfile-project-daimon-agy-subscription-realm-abc123" + } + ] + })); + + expect(planArtifactExports(report)).toEqual([]); + }); }); diff --git a/src/deployment/artifactsExportPlan.ts b/src/deployment/artifactsExportPlan.ts index d08532ab..b3def089 100644 --- a/src/deployment/artifactsExportPlan.ts +++ b/src/deployment/artifactsExportPlan.ts @@ -1,6 +1,7 @@ import path from "node:path/posix"; import type { CompileReport, ContainerPersistentMountReport } from "../report/index.js"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; /** One durable-artifact file `spawnfile artifacts export` egresses, and where it lands * under `` (Decision 21 §2 run-dir layout). Pure planning data — no Docker/IO here; @@ -168,6 +169,59 @@ const planDaimonFiles = ( }) ); +/** The persistent-mount id the Daimon adapter gives the per-turn engine usage ledger + * (`src/runtime/daimon/config.ts`). Deliberately still says `grok` now that AGY writes + * there too: the id is the volume's identity, and renaming it orphans every existing + * deployment's accumulated ledger. */ +const DAIMON_USAGE_MOUNT_ID = "daimon-grok-usage-ledger"; + +/** Daimon usage: the organization-wide per-turn engine token ledger. + * + * Unlike the causal streams above this is NOT per agent -- one exclusive-reattach volume + * carries every metered turn for the whole Daimon host -- so it exports flat under + * `raw/daimon/`, the way a single managed Moltnet network exports flat under + * `raw/moltnet/`. Flat also keeps it clear of the `raw/daimon//` directories the + * per-agent causal files use. + * + * BOTH generations ship. The broker rotates by size at + * `DAIMON_GROK_TURN_USAGE_LEDGER.rotateBytes`, so a long-lived deployment's earlier turns + * live in `usage.jsonl.1`; exporting only the live file would silently truncate history + * for exactly the runs expensive enough to care about. This is the same rotation the read + * path already handles (`src/runtime/usageLedgerRead.ts` reads both generations). + * + * The mount root IS the ledger directory (`mountPath` is + * `DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath`), so both names are resolved as + * mount-relative paths off the pinned contract constants rather than written out here -- + * if the contract ever moves the ledger, this follows it instead of silently exporting + * nothing. + * + * Optional, because absent must stay distinguishable from empty: a codex-only + * organization is never provisioned this volume and writes no ledger at all, which is + * legitimate, and the mount is absent from the report entirely in that case. Where the + * volume exists but a generation does not (no rotation yet, or no metered turn yet), the + * executor records it under `missingOptionalFiles` rather than as an exported empty file + * (`artifactsExport.ts`). */ +const planDaimonUsageFiles = ( + mounts: Map +): PlannedExportFile[] => { + const mount = mounts.get(DAIMON_USAGE_MOUNT_ID); + if (!mount) { + return []; + } + + return [ + DAIMON_GROK_TURN_USAGE_LEDGER.filePath, + DAIMON_GROK_TURN_USAGE_LEDGER.rotatedFilePath + ].map((absolutePath) => { + const volumePath = path.relative(DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, absolutePath); + return { + optional: true, + relativePath: `raw/daimon/${volumePath}`, + source: { kind: "volume" as const, volumeName: mount.volume_name, volumePath } + }; + }); +}; + /** Resolves every durable artifact file this run's compile report declares, and where each * one is read from. Pure function of the compile report alone (no deployment record, no * Docker) — `artifactsExport.ts` supplies the live container name at execution time. */ @@ -176,6 +230,7 @@ export const planArtifactExports = (report: CompileReport): PlannedExportFile[] return [ ...planMoltnetFiles(report, mounts), ...planMnemeFiles(report, mounts), - ...planDaimonFiles(report, mounts) + ...planDaimonFiles(report, mounts), + ...planDaimonUsageFiles(mounts) ]; }; From 6f2a9a1f3d1cc5d5f5c46577696781d76b360f7c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:25 +0200 Subject: [PATCH 22/34] fix(compiler): reattach durable memory and usage volumes across redeploys --- specs/CONTAINERS.md | 6 +- specs/SPEC.md | 2 + src/compiler/AGENTS.md | 21 +++++ src/compiler/containerArtifacts.test.ts | 84 ++++++++++++++++++-- src/compiler/containerArtifacts.ts | 50 +++++++++++- src/compiler/containerArtifactsPlans.test.ts | 29 ++++++- 6 files changed, 183 insertions(+), 9 deletions(-) diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index fa6262cd..7ceb23bd 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -596,7 +596,11 @@ produce an arm64 runtime image. Known credential files/directories are omitted and credential-shaped file content fails creation before archive publication. Blue/green runs use distinct run-scoped volumes, including author-named -volumes. Product-state transfer is a separate explicit operation over a strict +volumes, EXCEPT `exclusive-reattach` mounts. Durable memory stores and the +Daimon per-turn usage ledger are `exclusive-reattach`: their volumes are +named from the project root and deployment lineage, never the run id, so +they survive a redeploy. A report carrying one cannot use the concurrent +canary workflow below. Product-state transfer is a separate explicit operation over a strict `spawnfile.product-state-quiescence.v1` proof. Only listed regular files whose checksums remain stable before and after copying are cloned. Auth, credential, token, secret, session, wake, and SQLite paths are rejected; live volumes are diff --git a/specs/SPEC.md b/specs/SPEC.md index 65629dae..e6a305ba 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -378,6 +378,8 @@ Rules: - `memory[*].store.persistence.mode` MUST be `durable` or `ephemeral`. - If `memory[*].store.persistence` is omitted for `sqlite` or `json`, the compiler treats it as `durable`. - `memory[*].store.persistence.mode: durable` emits a persistent runtime mount for the store directory. +- That mount is `exclusive-reattach`: its volume is named from the project root and the deployment lineage, never the run id, so it survives a redeploy, and only one live container may hold it at a time. +- Two `memory[*]` entries MUST NOT resolve to the same durable store directory unless they declare the identical store, index, consolidation, and retention. The runtime keys a store by that directory and ignores the declared filename, so differing declarations would silently share one physical store. - `memory[*].index` is OPTIONAL. - If `memory[*].index` is omitted, the effective index intent is lexical enabled, vector disabled, graph disabled, and rerank disabled. - `memory[*].index.lexical.enabled` defaults to `true`. diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index 7a29958e..263651d3 100644 --- a/src/compiler/AGENTS.md +++ b/src/compiler/AGENTS.md @@ -104,6 +104,27 @@ src/compiler/ recomputing pi-internal engine resolution itself. This is the disclosure ground truth for a `scripted` (or any non-default) pi engine, so a scripted run is visibly scripted rather than an invisible test-only branch. +- `memoryArtifacts.ts` emits durable memory mounts with `lifecycle: + "exclusive-reattach"`, deliberately NOT run-scoped. `createPersistentVolumeName` + folds `NOOPOLIS_RUN_ID` into a volume name and `ensureNoopolisRunId` mints a + fresh id per `run`/`up`, so a run-scoped memory volume means the organization + redeployed tomorrow remembers nothing — and no working escape hatch exists + (`product-state clone` refuses SQLite paths; reusing yesterday's run id to + reproduce the name would collapse two causal runs onto one `run_id`). The + exclusive lifecycle's daemon-side reservation is a requirement here, not a + cost: Mneme's append-only JSONL plus its SQLite index are single-writer. The + consequence is that an organization with durable memory cannot use the + concurrent blue/green canary workflow and must stop-and-reattach + (`specs/CONTAINERS.md`), and two concurrent `spawnfile run` invocations of one + project now fail with an occupancy error instead of silently getting separate + empty banks. `daimon-grok-usage-ledger` + (`src/runtime/daimon/config.ts`) carries the same lifecycle for the same + reasons. An author-declared `persistence.name` is still honored verbatim. +- `memoryArtifacts.ts` also rejects two distinct banks resolving to one durable + directory. Mneme keys a store by its runtime home and discards the declared + filename, so `/d/a.jsonl` and `/d/b.jsonl` are one physical store with two + writers; only banks that declare themselves identically (the same bank stated + in an org scope and again in a nested team scope) may share a directory. - `daimonTelemetryArtifacts.ts` retains the legacy generated-Pi telemetry mount layout. The Phase-A public `runtime: daimon` host has no Spawnfile telemetry mount or Pi implementation path; add its public activity integration only in diff --git a/src/compiler/containerArtifacts.test.ts b/src/compiler/containerArtifacts.test.ts index 11bbdcce..aa826cc6 100644 --- a/src/compiler/containerArtifacts.test.ts +++ b/src/compiler/containerArtifacts.test.ts @@ -8,7 +8,7 @@ import type { ContainerTargetInput } from "../runtime/index.js"; import * as runtimeIndex from "../runtime/index.js"; import { createContainerArtifacts } from "./containerArtifacts.js"; import { createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; -import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; +import { createExclusiveReattachVolumeName } from "../shared/index.js"; import { openClawAdapter } from "../runtime/openclaw/adapter.js"; import { picoClawAdapter } from "../runtime/picoclaw/adapter.js"; import { piAdapter } from "../runtime/pi/adapter.js"; @@ -460,11 +460,11 @@ describe("createContainerArtifacts", () => { } ]); const dockerfile = result.files.find((file) => file.path === "Dockerfile")?.content ?? ""; - // Project-scoped (plan.root here is "/tmp/Spawnfile") via - // createPersistentVolumeName rather than a bare path slug; no - // NOOPOLIS_RUN_ID is set in this test process env, so no run segment. - const expectedVolumeName = createPersistentVolumeName( - "/tmp/Spawnfile", + // Durable memory volumes are deployment-lineage scoped, never run scoped: + // a run-scoped name gave every redeploy a fresh empty bank. This compile + // passes no deploymentLineage, so it lands on the "compile" lineage. + const expectedVolumeName = createExclusiveReattachVolumeName( + "/tmp/Spawnfile\u0000compile", "memory-var-lib-spawnfile-memory-assistant-shared-memory" ); @@ -492,17 +492,22 @@ describe("createContainerArtifacts", () => { retention: { forgetting: "manual" } } ]); + // The lifecycle must reach the distribution report too: the sourceless + // consume-image path derives its own volume name from these fields, and + // only the exclusive lifecycle gets the host-stable reattachable name. expect(result.distribution.report.persistent_mounts).toEqual([ { durability: "persistent", id: "memory-var-lib-spawnfile-memory-assistant-shared-memory", kind: "volume", + lifecycle: "exclusive-reattach", target: "/var/lib/spawnfile/memory/assistant/shared-memory" } ]); expect(result.report.persistent_mounts).toEqual([ { id: "memory-var-lib-spawnfile-memory-assistant-shared-memory", + lifecycle: "exclusive-reattach", mount_path: "/var/lib/spawnfile/memory/assistant/shared-memory", reason: "durable memory stores under /var/lib/spawnfile/memory/assistant/shared-memory", volume_name: expectedVolumeName @@ -1247,4 +1252,71 @@ describe("createContainerArtifacts distribution contract", () => { expect(instance?.node_ids).toEqual(["agent:research-cell"]); expect(Array.isArray(instance?.model_auth_methods)).toBe(false); }); + + /** + * `specs/SURFACES.md` promises a Moltnet release without `daimon-bridge` is + * rejected. No published release implements the daimon node runtime kind, and + * the node config decodes with DisallowUnknownFields, so an ungated compile + * ships a container that dies at boot on strict decode of `agent_id`. A node + * plan carries `receiptStorePath` if and only if its agent runtime is daimon. + */ + const piBridgeRelease = { + architecture: "amd64", + asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: `sha256:${"a".repeat(64)}`, + capabilities: ["pi-bridge"], + release_version: "v0.1.14", + source_revision: "b".repeat(40), + version: "spawnfile.moltnet-release-identity.v1" + } as const; + const daimonBridgeRelease = { + architecture: "amd64", + asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: `sha256:${"a".repeat(64)}`, + capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, + source_sha256: `sha256:${"c".repeat(64)}`, + version: "spawnfile.moltnet-release-identity.v1" + } as const; + const moltnetWith = (nodePlans: { configPath: string; networkId: string; receiptStorePath?: string }[]) => ({ + files: [], nodePlans, persistentMounts: [], ports: [], publishedPorts: [], serverPlans: [] + }); + const daimonPlans = moltnetWith([{ + configPath: "/etc/spawnfile/moltnet/mapper.json", + networkId: "daimon_lab", + receiptStorePath: "/var/lib/spawnfile/moltnet/networks/daimon_lab/daimon-receipts/mapper.json" + }]); + + const compileWith = (moltnet: unknown, moltnetRelease: unknown) => createContainerArtifacts( + createPlan(["openclaw"]), + [], + { hasStagedMoltnetBinaries: true, moltnet, moltnetRelease } as never + ); + + it("rejects a daimon Moltnet attachment when the staged release lacks daimon-bridge", async () => { + await expect(compileWith(daimonPlans, piBridgeRelease)).rejects.toThrow(/daimon-bridge/u); + + const thrown: unknown = await compileWith(daimonPlans, piBridgeRelease).catch((error: unknown) => error); + const message = thrown instanceof Error ? thrown.message : ""; + // Name the capability, the affected network, the real consequence, and a way out. + expect(message).toContain("daimon-bridge"); + expect(message).toContain("daimon_lab"); + expect(message).toContain("exit at boot"); + expect(message).toContain("build-local-moltnet.mjs"); + }); + + it("fails closed when a daimon Moltnet attachment has no staged release identity at all", async () => { + await expect(compileWith(daimonPlans, undefined)).rejects.toThrow(/daimon-bridge/u); + }); + + it("admits a daimon Moltnet attachment when the staged release advertises daimon-bridge", async () => { + const thrown: unknown = await compileWith(daimonPlans, daimonBridgeRelease).catch((error: unknown) => error); + expect(thrown instanceof Error ? thrown.message : "").not.toContain("daimon-bridge"); + }); + + it("leaves a pi-only Moltnet attachment on a pi-bridge release alone", async () => { + const piPlans = moltnetWith([{ configPath: "/etc/spawnfile/moltnet/pi.json", networkId: "lab" }]); + const thrown: unknown = await compileWith(piPlans, piBridgeRelease).catch((error: unknown) => error); + expect(thrown instanceof Error ? thrown.message : "").not.toContain("daimon-bridge"); + }); }); diff --git a/src/compiler/containerArtifacts.ts b/src/compiler/containerArtifacts.ts index 420af93f..b0f34455 100644 --- a/src/compiler/containerArtifacts.ts +++ b/src/compiler/containerArtifacts.ts @@ -36,11 +36,59 @@ export interface ContainerArtifactOptions { runtimePackageOverrides?: RuntimeContainerPackageOverrides; } +/** + * Enforces the `specs/SURFACES.md` Moltnet/daimon promise: "A release without + * `daimon-bridge` is rejected; the public pi-only release cannot be relabeled + * as dual-capability." + * + * A daimon Moltnet attachment lowers a node runtime config of + * `kind: "daimon"` carrying `agent_id`, `token_env`, and `receipt_store_path` + * (`./moltnetRuntimeConfig.ts`). No published Moltnet release implements that + * kind -- `pkg/bridgeconfig` has no `RuntimeDaimon` through the latest tag, and + * `pkg/nodeconfig` decodes with `DisallowUnknownFields()`, so the node exits on + * `agent_id` at strict decode, before `Validate()` is even reached. The + * entrypoint launches `moltnet node &` and then `wait -n` + * (`./containerEntrypointRender.ts`), so that exit tears the whole container + * down. Only a locally built Moltnet advertises `daimon-bridge` + * (`./localMoltnetAuthority.ts`); the pinned public authority is hard-narrowed + * to `["pi-bridge"]` (`./moltnetReleaseAuthority.ts`). + * + * This throws rather than warning, which is the opposite call from the other + * unlowerable-declaration diagnostics in this compiler. Those keep working + * projects compiling; here every affected project is already broken, and the + * failure it replaces is an opaque JSON decode error at container boot. + * + * `receiptStorePath` is set on a node plan if and only if the attached agent's + * runtime is daimon (`./moltnetArtifacts.ts`), so it is the exact daimon + * signal on an already-resolved plan. + */ +const assertMoltnetDaimonBridgeCapability = ( + moltnet: MoltnetArtifacts | undefined | null, + release: MoltnetReleaseIdentity | undefined +): void => { + const daimonNetworks = [...new Set((moltnet?.nodePlans ?? []) + .filter((nodePlan) => nodePlan.receiptStorePath !== undefined) + .map((nodePlan) => nodePlan.networkId))].sort(); + if (daimonNetworks.length === 0) return; + const capabilities: readonly string[] = release?.capabilities ?? []; + if (capabilities.includes("daimon-bridge")) return; + throw new SpawnfileError( + "compile_error", + `Moltnet release ${release?.version ?? "(unstaged)"} does not advertise the daimon-bridge capability, but ` + + `${daimonNetworks.length} network attachment(s) lower a daimon runtime bridge (${daimonNetworks.join(", ")}). ` + + "The published release implements pi-bridge only and rejects a daimon node config at strict JSON decode, so " + + "the container would exit at boot. Build Moltnet locally (scripts/build-local-moltnet.mjs, then " + + "SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR with SPAWNFILE_ALLOW_LOCAL_E2E=1) to stage a daimon-bridge release, or " + + "remove the Moltnet attachment from the daimon agent(s)." + ); +}; + export const createContainerArtifacts = async ( plan: CompilePlan, compiledNodes: CompiledNodeArtifact[], options: ContainerArtifactOptions = {} ): Promise => { + assertMoltnetDaimonBridgeCapability(options.moltnet, options.moltnetRelease); const localDaimonIdentityPath = process.env[DAIMON_LOCAL_RUNTIME_IDENTITY_ENV]?.trim(); const localDaimonIdentity = localDaimonIdentityPath ? await loadLocalDaimonRuntimeIdentity(localDaimonIdentityPath) : undefined; const runtimePlans = await createRuntimeTargetPlans(plan, compiledNodes, options.worldBindings, options.deploymentLineage); @@ -92,7 +140,7 @@ export const createContainerArtifacts = async ( .filter((variable) => variable.required && variable.categories.includes("runtime")) .map((variable) => variable.name) .sort(); - const memoryArtifacts = createMemoryArtifactBundle(plan); + const memoryArtifacts = createMemoryArtifactBundle(plan, options.deploymentLineage); const { resources: resolvedWorkspaceResources, mounts: workspaceResourceMounts } = resolveWorkspaceResourceVolumes(runtimePlans); const persistentMountsById = new Map(); diff --git a/src/compiler/containerArtifactsPlans.test.ts b/src/compiler/containerArtifactsPlans.test.ts index 41a9844a..126ec461 100644 --- a/src/compiler/containerArtifactsPlans.test.ts +++ b/src/compiler/containerArtifactsPlans.test.ts @@ -9,6 +9,7 @@ import { daimonAdapter } from "../runtime/daimon/adapter.js"; import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "../runtime/daimon/contractManifest.js"; import { createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; +import { createExclusiveReattachVolumeName } from "../shared/index.js"; import { createPersistentVolumeName } from "./moltnetArtifactPaths.js"; import type { CompilePlan, ResolvedAgentNode, ResolvedTeamNode } from "./types.js"; @@ -154,10 +155,24 @@ describe("runtime target plan source identity", () => { volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-runtime-home-assistant", undefined, "candidate-blue") }, { + // Run-id-free by construction. `createPersistentVolumeName` folds the + // run id in, so the previous (lifecycle-less) name changed on every + // `spawnfile up` and handed the container an empty keyring — meaning + // the interactive AGY browser OAuth had to be redone each deploy. id: "daimon-agy-subscription-realm", + lifecycle: "exclusive-reattach", mount_path: "/var/lib/spawnfile/daimon/agy-subscription-realm", reason: "Daimon host AGY subscription realm", - volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-agy-subscription-realm", undefined, "candidate-blue") + volume_name: createExclusiveReattachVolumeName("/tmp/Spawnfile\u0000compile", "daimon-agy-subscription-realm") + }, + { + // An AGY-only organization meters its turns too, so it gets the + // ledger volume that used to be provisioned only for Grok. + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger", + volume_name: createExclusiveReattachVolumeName("/tmp/Spawnfile\u0000compile", "daimon-grok-usage-ledger") }, { id: "daimon-organization-acceptance-store", @@ -223,6 +238,18 @@ describe("runtime target plan source identity", () => { reason: "Daimon host Grok subscription credential realm", volume_name: expect.stringMatching(/^spawnfile-exclusive-daimon-grok-subscription-realm-[a-f0-9]{16}$/u) }, + { + // Run-scoping this volume threw the ledger away on every redeploy: a + // fresh `spawnfile up` minted a new run id and therefore a new empty + // volume, so `spawnfile usage` could never report across deployments. + // Its single-writer, size-rotating append log is exactly the shape + // `exclusive-reattach` exists for. + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger", + volume_name: expect.stringMatching(/^spawnfile-exclusive-daimon-grok-usage-ledger-[a-f0-9]{16}$/u) + }, { id: "daimon-organization-acceptance-store", mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/state/wake-acceptance", From 2c67b1687131ffa8ea6b20b33e189882bd0e6998 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:29 +0200 Subject: [PATCH 23/34] fix(moltnet): select the binary probe by execution platform, not build target --- scripts/build-local-moltnet.mjs | 13 +++-- ...net-source-provenance.integration.test.mjs | 2 +- src/compiler/localMoltnetAuthority.test.ts | 25 ++++++++- src/compiler/localMoltnetAuthority.ts | 40 +++++++++++++-- src/compiler/moltnetBinaries.test.ts | 51 ++++++++++++++++++- src/compiler/moltnetBinaries.ts | 26 +++++++++- 6 files changed, 145 insertions(+), 12 deletions(-) diff --git a/scripts/build-local-moltnet.mjs b/scripts/build-local-moltnet.mjs index 2fc7b193..68115351 100644 --- a/scripts/build-local-moltnet.mjs +++ b/scripts/build-local-moltnet.mjs @@ -130,11 +130,11 @@ const assertBuiltBinaryCapabilities = (binaryPath) => { } }; -const assertDockerBinaryCapabilities = (binaryPath) => { +const assertDockerBinaryCapabilities = (binaryPath, arch) => { for (const kind of ["pi", "daimon"]) { const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-moltnet-probe-")), configPath = path.join(temporaryDirectory, "config.json"), receiptDirectory = path.join(temporaryDirectory, "receipts"); mkdirSync(receiptDirectory); writeFileSync(configPath, JSON.stringify(createCapabilityProbeConfig(kind, "/receipts/agent.json"))); - const id = execFileSync("docker", ["create", "--platform", "linux/amd64", "--env", "SPAWNFILE_DAIMON_CONTROL_TOKEN=probe", "node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df", "timeout", "2", "/moltnet", "node", "/config.json"], { encoding: "utf8" }).trim(); + const id = execFileSync("docker", ["create", "--platform", `linux/${arch}`, "--env", "SPAWNFILE_DAIMON_CONTROL_TOKEN=probe", "node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df", "timeout", "2", "/moltnet", "node", "/config.json"], { encoding: "utf8" }).trim(); try { execFileSync("docker", ["cp", binaryPath, `${id}:/moltnet`]); execFileSync("docker", ["cp", configPath, `${id}:/config.json`]); execFileSync("docker", ["cp", receiptDirectory, `${id}:/receipts`]); const result = spawnSync("docker", ["start", "--attach", id], { encoding: "utf8" }); const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; if (result.status !== 124 && !/connection refused|connect:|dial tcp|network is unreachable/iu.test(output)) throw new Error(`Built Moltnet binary does not accept ${kind}-bridge: ${output.trim()}`); } finally { execFileSync("docker", ["rm", "--force", id], { stdio: "ignore" }); rmSync(temporaryDirectory, { force: true, recursive: true }); } } @@ -166,13 +166,18 @@ const main = () => { const source = requiredBundle("SPAWNFILE_MOLTNET_SOURCE_BUNDLE", "build-source"), dependencies = requiredBundle("SPAWNFILE_MOLTNET_GO_DEPENDENCY_BUNDLE", "go-dependencies"), sourceContext = path.join(workDirectory, "source"), dependencyContext = path.join(workDirectory, "dependencies"), output = path.join(workDirectory, "output"); mkdirSync(sourceContext); mkdirSync(dependencyContext); mkdirSync(output); writeFileSync(path.join(sourceContext, "source.tar"), readFileSync(source.path)); writeFileSync(path.join(dependencyContext, "dependencies.tar"), readFileSync(dependencies.path)); execFileSync("docker", ["build", "--network=none", "--platform", `linux/${goarchForHost()}`, "--build-context", `source_bundle=${sourceContext}`, "--build-context", `dependency_bundle=${dependencyContext}`, "--output", `type=local,dest=${output}`, "--build-arg", `SOURCE_ARCHIVE_SHA256=${source.archive_sha256}`, "--build-arg", `DEPENDENCY_ARCHIVE_SHA256=${dependencies.archive_sha256}`, "-f", path.join(repoRoot, "runtime-images", "moltnet", "SourceBundle.Dockerfile"), repoRoot], { stdio: "inherit" }); - writeFileSync(binaryPath, readFileSync(path.join(output, "moltnet")), { mode: 0o755 }); assertDockerBinaryCapabilities(binaryPath); + writeFileSync(binaryPath, readFileSync(path.join(output, "moltnet")), { mode: 0o755 }); assertDockerBinaryCapabilities(binaryPath, arch); } else execFileSync("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", binaryPath, "./cmd/moltnet"], { cwd: moltnetDir, env: { ...process.env, CGO_ENABLED: "0", GOARCH: arch, GOOS: "linux", GOTOOLCHAIN: "local" }, stdio: "inherit" }); - if (!archiveMode) assertBuiltBinaryCapabilities(binaryPath); + // The build is always GOOS=linux, so a matching GOARCH on a non-Linux host + // (darwin/arm64 vs linux/arm64) still cannot exec the ELF: probe via Docker. + if (!archiveMode) { + if (process.platform === "linux") assertBuiltBinaryCapabilities(binaryPath); + else assertDockerBinaryCapabilities(binaryPath, arch); + } mkdirSync(releaseDir, { recursive: true }); execFileSync("tar", ["-C", workDirectory, "-czf", assetPath, "moltnet"], { stdio: "inherit" }); } finally { diff --git a/scripts/moltnet-source-provenance.integration.test.mjs b/scripts/moltnet-source-provenance.integration.test.mjs index 671d52e4..cf871b4a 100644 --- a/scripts/moltnet-source-provenance.integration.test.mjs +++ b/scripts/moltnet-source-provenance.integration.test.mjs @@ -18,6 +18,6 @@ test("literal dirty-tree Moltnet archive wrapper emits an amd64 provenance-bound const stamp = JSON.parse(readFileSync(path.join(release, "local_moltnet_release_stamp_amd64.json"), "utf8")); assert.equal(stamp.arch, "amd64"); assert.equal(stamp.source_inputs.mode, "source-bundle"); assert.match(stamp.source_inputs.source_sha256, /^sha256:[a-f0-9]{64}$/u); assert.match(stamp.source_inputs.dependencies_sha256, /^sha256:[a-f0-9]{64}$/u); assert.ok(readFileSync(path.join(release, stamp.asset)).length > 1_000_000); - execFileSync("node", ["--import", "tsx", "--input-type=module", "-e", `import {readLocalMoltnetReleaseIdentity as read} from './src/compiler/localMoltnetAuthority.ts'; const value=await read(${JSON.stringify(release)},'amd64',${JSON.stringify(process.arch === "arm64" ? "arm64" : "amd64")}); if(value.source_inputs?.source_sha256!==value.source_sha256) process.exit(2);`], { cwd: repository, stdio: "inherit" }); + execFileSync("node", ["--import", "tsx", "--input-type=module", "-e", `import {readLocalMoltnetReleaseIdentity as read} from './src/compiler/localMoltnetAuthority.ts'; const value=await read(${JSON.stringify(release)},'amd64',{platform:process.platform,architecture:${JSON.stringify(process.arch === "arm64" ? "arm64" : "amd64")}}); if(value.source_inputs?.source_sha256!==value.source_sha256) process.exit(2);`], { cwd: repository, stdio: "inherit" }); } finally { rmSync(temporary, { force: true, recursive: true }); } }); diff --git a/src/compiler/localMoltnetAuthority.test.ts b/src/compiler/localMoltnetAuthority.test.ts index be6ae40f..2bfca169 100644 --- a/src/compiler/localMoltnetAuthority.test.ts +++ b/src/compiler/localMoltnetAuthority.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { createLocalMoltnetBridgeProbeConfig, parseLocalReleaseStamp } from "./localMoltnetAuthority.js"; +import { createLocalMoltnetBridgeProbeConfig, moltnetBinaryIsDirectlyExecutable, parseLocalReleaseStamp } from "./localMoltnetAuthority.js"; describe("local Moltnet bridge capability probes", () => { it("represents every required Daimon runtime field with private-state-compatible paths", () => { @@ -29,4 +29,27 @@ describe("local Moltnet bridge capability probes", () => { expect(parseLocalReleaseStamp(JSON.stringify(stamp), "amd64").source_inputs).toEqual(stamp.source_inputs); expect(() => parseLocalReleaseStamp(JSON.stringify({ ...stamp, source_inputs: { ...stamp.source_inputs, dependencies_sha256: "sha256:bad" } }), "amd64")).toThrow(/complete development-only/u); }); + + /** + * A local Moltnet build is always GOOS=linux. Matching only the CPU + * architecture calls a darwin/arm64 host "directly executable" for a + * linux/arm64 ELF, which fails with ENOEXEC and rejects a good build. The + * host has to match on OS as well, and everything else takes the Docker + * probe, which can run a linux image anywhere. + */ + it("runs the built binary directly only when the host OS and architecture both match", () => { + // The regression: same CPU architecture, different OS. Must NOT exec directly. + expect(moltnetBinaryIsDirectlyExecutable("arm64", { architecture: "arm64", platform: "darwin" })).toBe(false); + expect(moltnetBinaryIsDirectlyExecutable("amd64", { architecture: "amd64", platform: "win32" })).toBe(false); + + // The Linux build path must keep exec'ing directly -- Docker is the fallback, not the default. + expect(moltnetBinaryIsDirectlyExecutable("amd64", { architecture: "amd64", platform: "linux" })).toBe(true); + expect(moltnetBinaryIsDirectlyExecutable("arm64", { architecture: "arm64", platform: "linux" })).toBe(true); + + // Genuine cross-architecture on Linux still goes through Docker. + expect(moltnetBinaryIsDirectlyExecutable("arm64", { architecture: "amd64", platform: "linux" })).toBe(false); + + // A host CPU Spawnfile cannot name as a target can never match one. + expect(moltnetBinaryIsDirectlyExecutable("amd64", { platform: "linux" })).toBe(false); + }); }); diff --git a/src/compiler/localMoltnetAuthority.ts b/src/compiler/localMoltnetAuthority.ts index 93a18244..403af2e4 100644 --- a/src/compiler/localMoltnetAuthority.ts +++ b/src/compiler/localMoltnetAuthority.ts @@ -132,10 +132,42 @@ export const parseLocalReleaseStamp = ( return value as unknown as LocalMoltnetReleaseStamp; }; +/** + * The platform actually executing this compile -- never the build target. + * + * `architecture` is absent when the host CPU is one Spawnfile cannot name as a + * Moltnet target (so it can never accidentally equal one), and `platform` is a + * raw `process.platform`. + */ +export interface MoltnetExecutionHost { + readonly architecture?: MoltnetTargetArchitecture; + readonly platform: NodeJS.Platform; +} + +/** + * Whether this host can run the built binary directly, deciding between the + * direct-exec probe and the Docker cross-platform probe below. + * + * A local Moltnet build is ALWAYS `GOOS=linux` (the asset is + * `moltnet_linux_.tar.gz`), so matching the architecture alone is not + * enough: a darwin/arm64 host and a linux/arm64 binary share a CPU + * architecture and share nothing else. Executing that ELF on macOS fails with + * ENOEXEC and the probe reports the binary as unprovable, rejecting a + * perfectly good build. The host must therefore match on OS *and* + * architecture; everything else goes through Docker, which can run a + * linux/ image anywhere. + * + * @internal Exported for the branch-selection tests. + */ +export const moltnetBinaryIsDirectlyExecutable = ( + architecture: MoltnetTargetArchitecture, + host: MoltnetExecutionHost +): boolean => host.platform === "linux" && host.architecture === architecture; + const verifyBuiltMoltnetArchive = async ( releaseAssetPath: string, architecture: MoltnetTargetArchitecture, - hostArchitecture: MoltnetTargetArchitecture + host: MoltnetExecutionHost ): Promise => { const temporaryDirectory = path.join(path.dirname(releaseAssetPath), `.spawnfile-moltnet-verify-${process.pid}-${Date.now()}`); try { @@ -146,7 +178,7 @@ const verifyBuiltMoltnetArchive = async ( throw new SpawnfileError("compile_error", "Local Moltnet archive does not contain its moltnet binary"); } await chmod(binaryPath, 0o755); - if (architecture === hostArchitecture) { + if (moltnetBinaryIsDirectlyExecutable(architecture, host)) { const { stdout } = await execFile(binaryPath, ["version"]); if (!stdout.trim()) throw new SpawnfileError("compile_error", "Local Moltnet binary did not produce a bounded version identity"); await assertBridgeCapability(binaryPath, temporaryDirectory, "pi"); await assertBridgeCapability(binaryPath, temporaryDirectory, "daimon"); } else { @@ -165,7 +197,7 @@ const verifyBuiltMoltnetArchive = async ( export const readLocalMoltnetReleaseIdentity = async ( releaseDirectory: string, architecture: MoltnetTargetArchitecture, - hostArchitecture: MoltnetTargetArchitecture + host: MoltnetExecutionHost ): Promise => { const asset = assetName(architecture); const assetPath = path.join(releaseDirectory, asset); @@ -178,7 +210,7 @@ export const readLocalMoltnetReleaseIdentity = async ( if (stamp.sha256 !== sha256) { throw new SpawnfileError("compile_error", "Local Moltnet development stamp does not match its archive bytes"); } - await verifyBuiltMoltnetArchive(assetPath, architecture, hostArchitecture); + await verifyBuiltMoltnetArchive(assetPath, architecture, host); return Object.freeze({ architecture, asset, diff --git a/src/compiler/moltnetBinaries.test.ts b/src/compiler/moltnetBinaries.test.ts index d536585c..396f0b92 100644 --- a/src/compiler/moltnetBinaries.test.ts +++ b/src/compiler/moltnetBinaries.test.ts @@ -281,7 +281,9 @@ describe("moltnetBinaries", () => { vi.stubEnv("SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR", releaseDirectory); vi.stubEnv("SPAWNFILE_ALLOW_LOCAL_E2E", "1"); - await expect(stageMoltnetBinaries(outputDirectory, { architecture })).rejects.toThrow(/does not accept daimon-bridge/u); + await expect(stageMoltnetBinaries(outputDirectory, { + architecture, executionHost: { architecture, platform: "linux" } + })).rejects.toThrow(/does not accept daimon-bridge/u); }); it("rejects malformed local identities before extraction", async () => { @@ -436,4 +438,51 @@ describe("moltnetBinaries", () => { await expect(stageFakeRelease(outputDirectory, releaseDirectory, architecture)) .rejects.toThrow(/sha256 does not match/u); }); + + /** + * The regression: `stageMoltnetBinaries` used to pass `resolveTargetArchitecture()` + * as the probe's HOST architecture. That helper honours the + * SPAWNFILE_MOLTNET_TARGET_ARCH build-target override, so host and target were the + * same value by construction, `architecture === hostArchitecture` was always true, + * and the direct-exec branch was the only branch reachable. On macOS that means + * spawning a linux ELF, which fails ENOEXEC and rejects a correct local build. + * + * Here the build target is pinned to the architecture this host is NOT, with no + * explicit host override. A direct-exec probe would run the shell-script stand-in + * and report the daimon rejection it is written to produce; the fixed caller + * resolves the real host instead, so that message must never appear. + */ + it("resolves the probe host from the platform, not from the build-target override", async () => { + const hostArchitecture = process.arch === "arm64" ? "arm64" : "amd64"; + const targetArchitecture = hostArchitecture === "arm64" ? "amd64" : "arm64"; + const releaseDirectory = await createFakeReleaseDirectory(["moltnet"], targetArchitecture); + const asset = `moltnet_linux_${targetArchitecture}.tar.gz`; + const binaryPath = path.join(releaseDirectory, "payload", "moltnet"); + await writeUtf8File(binaryPath, [ + "#!/usr/bin/env sh", + "if [ \"$1\" = version ]; then echo moltnet; exit 0; fi", + "echo unsupported >&2; exit 1" + ].join("\n") + "\n"); + await chmod(binaryPath, 0o755); + await execFile("tar", ["-C", path.join(releaseDirectory, "payload"), "-czf", path.join(releaseDirectory, asset), "."]); + const sha256 = createHash("sha256").update(await readFile(path.join(releaseDirectory, asset))).digest("hex"); + await writeUtf8File(path.join(releaseDirectory, `local_moltnet_release_stamp_${targetArchitecture}.json`), `${JSON.stringify({ + arch: targetArchitecture, asset, capabilities: ["daimon-bridge", "pi-bridge"], + development: { mode: "local-development", non_production: true, unsigned: true, unpublished: true }, + sha256, source_sha256: `sha256:${"f".repeat(64)}`, + stamp_version: "spawnfile.local-moltnet-release-stamp.v1" + })}\n`); + const outputDirectory = await createTempDirectory("spawnfile-moltnet-local-out-"); + vi.stubEnv("SPAWNFILE_LOCAL_MOLTNET_RELEASE_DIR", releaseDirectory); + vi.stubEnv("SPAWNFILE_ALLOW_LOCAL_E2E", "1"); + vi.stubEnv("SPAWNFILE_MOLTNET_TARGET_ARCH", targetArchitecture); + + const thrown: unknown = await stageMoltnetBinaries(outputDirectory).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(Error); + // Every direct-exec probe failure names "Local Moltnet binary"; the Docker + // cross-platform probe says "cross-host". This host cannot execute that + // target, so the direct branch must never have been entered. + expect((thrown as Error).message).not.toMatch(/Local Moltnet binary/u); + }); }); diff --git a/src/compiler/moltnetBinaries.ts b/src/compiler/moltnetBinaries.ts index d0ca2021..805630f1 100644 --- a/src/compiler/moltnetBinaries.ts +++ b/src/compiler/moltnetBinaries.ts @@ -16,6 +16,7 @@ import { import { downloadTrustedMoltnetReleaseAsset } from "./moltnetReleaseDownload.js"; import { readLocalMoltnetReleaseIdentity, + type MoltnetExecutionHost, type LocalMoltnetReleaseIdentity } from "./localMoltnetAuthority.js"; @@ -56,6 +57,8 @@ export type MoltnetReleaseIdentity = PublishedMoltnetReleaseIdentity | LocalMolt export interface MoltnetBinaryStageOptions { readonly architecture?: MoltnetTargetArchitecture; + /** Overrides the detected execution host for the local-build capability probe. Tests only. */ + readonly executionHost?: MoltnetExecutionHost; /** Explicit local source directory; bytes remain bound to trusted authority. */ readonly releaseDirectory?: string; } @@ -118,6 +121,27 @@ const resolveTargetArchitecture = ( } }; +/** + * The platform executing this compile, for the local-build capability probe. + * + * Deliberately NOT `resolveTargetArchitecture()`: that helper honours the + * `SPAWNFILE_MOLTNET_TARGET_ARCH` build-target override and falls back to + * `process.arch`, so passing it as the host made the host and the target the + * same value by construction and pinned the probe to its direct-exec branch. + * This reads the real host only, and reports `architecture: undefined` for a + * CPU that is not a nameable Moltnet target rather than throwing -- an + * unsupported host is not a compile error, it just cannot exec the binary + * directly. + * + * Private: this module's export surface is pinned by test. + */ +const resolveMoltnetExecutionHost = (): MoltnetExecutionHost => ({ + ...(process.arch === "arm64" + ? { architecture: "arm64" as const } + : process.arch === "x64" ? { architecture: "amd64" as const } : {}), + platform: process.platform +}); + const createReleaseAssetName = (architecture: string): string => `moltnet_${MOLTNET_TARGET_OS}_${architecture}.tar.gz`; @@ -359,7 +383,7 @@ export const stageMoltnetBinaries = async ( const identity = await readLocalMoltnetReleaseIdentity( localReleaseDirectory, architecture, - resolveTargetArchitecture() + options.executionHost ?? resolveMoltnetExecutionHost() ); return stageMoltnetReleaseAsset( outputDirectory, From f27b2ee022c869d30caecd24ac446e395ca6658c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:29 +0200 Subject: [PATCH 24/34] feat(compiler): reject a daimon attachment when the staged Moltnet lacks daimon-bridge --- src/e2e/daimonOrg.ts | 29 ++---------------- src/e2e/runtimeRootfsPaths.test.ts | 33 ++++++++++++++++++++- src/e2e/runtimeRootfsPaths.ts | 47 ++++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/e2e/daimonOrg.ts b/src/e2e/daimonOrg.ts index 3f1e4f8f..b9581a18 100644 --- a/src/e2e/daimonOrg.ts +++ b/src/e2e/daimonOrg.ts @@ -19,6 +19,7 @@ import { applyRuntimePackageOverrides, type RuntimePackageOverrides } from "./runtimePackageOverrides.js"; +import { rewriteMemoryPaths, toRootfsPath } from "./runtimeRootfsPaths.js"; const execFile = promisify(execFileCallback); @@ -51,9 +52,6 @@ interface CodexAuthFile { const fixturesRoot = path.resolve(process.cwd(), "examples", "daimon-org"); -const toRootfsPath = (rootfs: string, containerPath: string): string => - path.join(rootfs, containerPath.replace(/^\/+/u, "")); - const decodeJwtExpiry = (accessToken: string): number => { const payload = accessToken.split(".")[1]; if (!payload) { @@ -114,27 +112,6 @@ const writePiAuth = async ( await writeUtf8File(codexCliAuthPath, codexAuthContent); }; -const rewriteConfigMemoryPathsForHostE2E = async ( - configPath: string, - rootfs: string -): Promise => { - const config = JSON.parse(await readUtf8File(configPath)) as { - agents?: Array<{ memory?: { runtime_home_path?: string } }>; - }; - let memoryEventsPath = ""; - for (const agent of config.agents ?? []) { - const memory = agent.memory; - if (!memory?.runtime_home_path?.startsWith("/")) { - continue; - } - const containerMemoryPath = memory.runtime_home_path; - memory.runtime_home_path = toRootfsPath(rootfs, containerMemoryPath); - memoryEventsPath ||= path.join(memory.runtime_home_path, "memory", "events.jsonl"); - } - await writeUtf8File(configPath, `${JSON.stringify(config, null, 2)}\n`); - return memoryEventsPath; -}; - const readJsonl = async (filePath: string): Promise => { const content = await readUtf8File(filePath); return content.split(/\r?\n/u) @@ -238,7 +215,7 @@ export const runDaimonOrgE2E = async ( path.join(runtimeRoot, "package.json"), options.runtimePackageOverrides ); - const memoryEventsPath = await rewriteConfigMemoryPathsForHostE2E(configPath, rootfs); + const memoryEventsPath = await rewriteMemoryPaths(configPath, rootfs); await execFile(options.npmCommand ?? "npm", [ "install", "--omit=dev", @@ -261,7 +238,7 @@ export const runDaimonOrgE2E = async ( await execFile(options.nodeCommand ?? "node", appArgs, { env: appEnv }); const notes = await assertSharedNotes(rootfs, instance.workspace_path); - const memoryEvents = memoryEventsPath ? await readJsonl<{ type?: string }>(memoryEventsPath) : []; + const memoryEvents = await readJsonl<{ type?: string }>(memoryEventsPath); const memoryRecallCount = memoryEvents.filter((event) => event.type === "memory.recalled").length; if (memoryEvents.length === 0 || memoryRecallCount === 0) { throw new SpawnfileError( diff --git a/src/e2e/runtimeRootfsPaths.test.ts b/src/e2e/runtimeRootfsPaths.test.ts index ec8b2274..841dd4fa 100644 --- a/src/e2e/runtimeRootfsPaths.test.ts +++ b/src/e2e/runtimeRootfsPaths.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { readUtf8File, removeDirectory, writeUtf8File } from "../filesystem/index.js"; -import { rewriteMemoryPaths, toRootfsPath } from "./runtimeRootfsPaths.js"; +import { + PI_APP_CONFIG_MEMORY_HOME_KEY, + rewriteMemoryPaths, + toRootfsPath +} from "./runtimeRootfsPaths.js"; const cleanupDirs: string[] = []; afterEach(async () => { @@ -54,4 +58,31 @@ describe("runtimeRootfsPaths", () => { path.join(directory, "rootfs", "var/lib/spawnfile/memory/eleanor") ); }); + + /** + * A rewrite that matches nothing used to be a silent no-op returning "", + * which pushed the failure downstream: the harness left the runtime pointed + * at container-absolute paths on the host and only reported "no memories + * were recalled" after two live model runs. The generated-Pi config's key is + * snake_case while the Daimon organization runtime's is camelCase, so a + * harness aimed at the wrong config is exactly the drift this must catch. + */ + it("fails loudly instead of silently rewriting nothing", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-rootfs-paths-nomatch-")); + cleanupDirs.push(directory); + const configPath = path.join(directory, "config.json"); + await writeUtf8File( + configPath, + JSON.stringify({ + agents: [{ memory: { runtimeHomePath: "/var/lib/spawnfile/memory/arun" } }] + }) + ); + + await expect(rewriteMemoryPaths(configPath, path.join(directory, "rootfs"))) + .rejects.toThrow(/Rewrote no agent memory home/u); + }); + + it("names the generated-Pi app config key it rewrites", () => { + expect(PI_APP_CONFIG_MEMORY_HOME_KEY).toBe("runtime_home_path"); + }); }); diff --git a/src/e2e/runtimeRootfsPaths.ts b/src/e2e/runtimeRootfsPaths.ts index 9e6ce19d..fc403fc1 100644 --- a/src/e2e/runtimeRootfsPaths.ts +++ b/src/e2e/runtimeRootfsPaths.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { readUtf8File, writeUtf8File } from "../filesystem/index.js"; +import { SpawnfileError } from "../shared/index.js"; /** * Shared local-rootfs path helpers used by E2E flows that compile a project @@ -12,16 +13,34 @@ import { readUtf8File, writeUtf8File } from "../filesystem/index.js"; export const toRootfsPath = (rootfs: string, containerPath: string): string => path.join(rootfs, containerPath.replace(/^\/+/u, "")); +/** + * The generated-Pi app config's memory key, in the exact case the Pi app + * config emitter writes it (`src/runtime/pi/appAgentConfig.ts`, typed in + * `src/runtime/pi/appTemplateTypes.ts`). It is deliberately snake_case and + * deliberately NOT the Daimon organization runtime's camelCase + * `memory.runtimeHomePath` (`src/runtime/daimon/config.ts`): these are two + * different configs consumed by two different runtimes, and only the Pi one + * is rewritten onto a host rootfs by these harnesses. + */ +export const PI_APP_CONFIG_MEMORY_HOME_KEY = "runtime_home_path" as const; + interface RewriteMemoryPathsConfig { - agents?: Array<{ memory?: { runtime_home_path?: string } }>; + agents?: Array<{ memory?: { [PI_APP_CONFIG_MEMORY_HOME_KEY]?: string } }>; } /** - * Rewrites every agent's `memory.runtime_home_path` in a generated Pi/Daimon - * app config from its container-absolute path onto the equivalent local - * rootfs path, then returns the first rewritten agent's memory events.jsonl - * path (the conventional location E2E flows read generated memory events - * from). + * Rewrites every agent's `memory.runtime_home_path` in a generated Pi app + * config from its container-absolute path onto the equivalent local rootfs + * path, then returns the first rewritten agent's memory events.jsonl path + * (the conventional location E2E flows read generated memory events from). + * + * Throws when it rewrites nothing. A rewrite that silently matches no agent + * is never a legitimate outcome for a caller that then reads memory events + * off the returned path: it would leave the runtime pointed at a + * container-absolute path on the host and surface much later as an empty + * ledger. A key-case or schema drift in the emitter must fail here, loudly, + * at the rewrite — not as a confusing "no memories were recalled" assertion + * several minutes of live model calls later. */ export const rewriteMemoryPaths = async ( configPath: string, @@ -31,11 +50,21 @@ export const rewriteMemoryPaths = async ( let eventsPath = ""; for (const agent of config.agents ?? []) { const memory = agent.memory; - if (!memory?.runtime_home_path?.startsWith("/")) { + const containerHomePath = memory?.[PI_APP_CONFIG_MEMORY_HOME_KEY]; + if (!memory || !containerHomePath?.startsWith("/")) { continue; } - memory.runtime_home_path = toRootfsPath(rootfs, memory.runtime_home_path); - eventsPath ||= path.join(memory.runtime_home_path, "memory", "events.jsonl"); + memory[PI_APP_CONFIG_MEMORY_HOME_KEY] = toRootfsPath(rootfs, containerHomePath); + eventsPath ||= path.join(memory[PI_APP_CONFIG_MEMORY_HOME_KEY]!, "memory", "events.jsonl"); + } + if (!eventsPath) { + throw new SpawnfileError( + "runtime_error", + `Rewrote no agent memory home in ${configPath}: no agent declared an absolute ` + + `memory.${PI_APP_CONFIG_MEMORY_HOME_KEY}. Either the project declares no durable ` + + `memory bank, or the generated config's memory shape changed and this rewrite no ` + + `longer matches it.` + ); } await writeUtf8File(configPath, `${JSON.stringify(config, null, 2)}\n`); return eventsPath; From cd08a41158bb42f7a57d27842d21aa330d7dadaf Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:51:41 +0200 Subject: [PATCH 25/34] ci: check out a Daimon fixture for the source-provenance build test --- .github/workflows/test.yml | 12 ++++++++++++ .../source-provenance-bundle.integration.test.mjs | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30d78f6a..6974fe2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,9 +42,21 @@ jobs: - name: Build run: npm run build + - name: Check out Daimon provenance fixture + uses: actions/checkout@v4 + with: + repository: noopolis/daimon + path: daimon-fixture + - name: Verify Git-free offline linux/amd64 source build + env: + SPAWNFILE_TEST_DAIMON_SOURCE: ${{ github.workspace }}/daimon-fixture run: npm run test:source-provenance-docker + - name: Remove Daimon provenance fixture + if: always() + run: rm -rf daimon-fixture + - name: Check out Moltnet provenance fixture uses: actions/checkout@v4 with: diff --git a/scripts/source-provenance-bundle.integration.test.mjs b/scripts/source-provenance-bundle.integration.test.mjs index 7879a3e7..2cb25b83 100644 --- a/scripts/source-provenance-bundle.integration.test.mjs +++ b/scripts/source-provenance-bundle.integration.test.mjs @@ -17,7 +17,13 @@ test("actual Daimon lock produces a real offline linux/amd64 shipped artifact an const temporary = mkdtempSync(path.join(repository, ".spawnfile-source-docker-")); let registry; try { - const closure = path.join(temporary, "closure"), actualDaimon = path.resolve(repository, "..", "daimon"), daimonSource = path.join(temporary, "actual-daimon-input"); + const closure = path.join(temporary, "closure"), + // CI has no sibling checkout, so it points this at a fetched fixture the + // way SPAWNFILE_TEST_MOLTNET_SOURCE already does for the Moltnet variant. + actualDaimon = process.env.SPAWNFILE_TEST_DAIMON_SOURCE + ? path.resolve(process.env.SPAWNFILE_TEST_DAIMON_SOURCE) + : path.resolve(repository, "..", "daimon"), + daimonSource = path.join(temporary, "actual-daimon-input"); mkdirSync(daimonSource); cpSync(path.join(actualDaimon, "package.json"), path.join(daimonSource, "package.json")); cpSync(path.join(actualDaimon, "package-lock.json"), path.join(daimonSource, "package-lock.json")); execFileSync("npm", ["run", "--silent", "prepare:linux-amd64-closure", "--", daimonSource, closure, "0.142.3"], { cwd: repository, stdio: "inherit" }); const sourceTar = path.join(temporary, "source.tar"), dependencyTar = path.join(temporary, "dependencies.tar"); From e50fef5d870603c0fe02a3d526cb3bc38655bd74 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 14:16:50 +0200 Subject: [PATCH 26/34] feat(moltnet): pin release v0.1.18 and admit its daimon-bridge capability --- moltnet-releases.json | 9 ++-- src/compiler/containerArtifacts.test.ts | 26 ++++++++++ src/compiler/containerArtifacts.ts | 7 +-- src/compiler/moltnetBinaries.ts | 25 ++++++--- src/compiler/moltnetReleaseAuthority.test.ts | 54 ++++++++++++++++++-- src/compiler/moltnetReleaseAuthority.ts | 43 ++++++++++++++-- src/compiler/upReceipt.test.ts | 28 ++++++++++ src/compiler/upReceipt.ts | 25 +++++++-- src/deployment/upReceiptTypes.ts | 7 ++- src/e2e/localMoltnetRelease.ts | 5 +- 10 files changed, 196 insertions(+), 33 deletions(-) diff --git a/moltnet-releases.json b/moltnet-releases.json index f28b91eb..37019e69 100644 --- a/moltnet-releases.json +++ b/moltnet-releases.json @@ -1,20 +1,21 @@ { "version": "spawnfile.moltnet-release-authority.v1", - "release_version": "v0.1.14", - "source_revision": "7baeb284ba0b1b5e454476141a557d68b5a4af0d", + "release_version": "v0.1.18", + "source_revision": "988c5284f45705beb3bf59a4a4c0008605ce609e", "capabilities": [ + "daimon-bridge", "pi-bridge" ], "assets": [ { "architecture": "amd64", "asset": "moltnet_linux_amd64.tar.gz", - "asset_sha256": "sha256:a2e7a0acd44ab548a81d99e51401ebdee9f50a539b24c9450fc12d8e9218e5f6" + "asset_sha256": "sha256:92d356cd33841e89b6bc56c6e8c2c37d987124f35c896d033e8766eab09da2c5" }, { "architecture": "arm64", "asset": "moltnet_linux_arm64.tar.gz", - "asset_sha256": "sha256:3d46ad047496dd32a9ad41901e7ae1f10a25e9a21f3b29d3b6ccd2cff62fa58f" + "asset_sha256": "sha256:40c1fae1c687a59a9e6d804ac28d00e925f318bd4e922accdca4402ceab88548" } ] } diff --git a/src/compiler/containerArtifacts.test.ts b/src/compiler/containerArtifacts.test.ts index aa826cc6..93db8258 100644 --- a/src/compiler/containerArtifacts.test.ts +++ b/src/compiler/containerArtifacts.test.ts @@ -7,6 +7,7 @@ import type { CompilePlan, ResolvedAgentNode, ResolvedMemoryBank } from "./types import type { ContainerTargetInput } from "../runtime/index.js"; import * as runtimeIndex from "../runtime/index.js"; import { createContainerArtifacts } from "./containerArtifacts.js"; +import { readTrustedMoltnetReleaseAuthority, trustedMoltnetReleaseAsset } from "./moltnetReleaseAuthority.js"; import { createRuntimeTargetPlans } from "./containerArtifactsPlans.js"; import { createExclusiveReattachVolumeName } from "../shared/index.js"; import { openClawAdapter } from "../runtime/openclaw/adapter.js"; @@ -1314,6 +1315,31 @@ describe("createContainerArtifacts distribution contract", () => { expect(thrown instanceof Error ? thrown.message : "").not.toContain("daimon-bridge"); }); + /** + * THE TRANSITION. Before moltnet v0.1.18 no published release advertised + * `daimon-bridge`, so the fail-closed gate rejected every daimon attachment — + * correctly, because the pinned binary rejected the config at strict decode. + * Now the checked-in authority advertises it, so the same organization must + * compile. Nothing else pins that flip, and it is the entire point of the pin. + */ + it("admits a daimon Moltnet attachment against the checked-in released authority", async () => { + const authority = await readTrustedMoltnetReleaseAuthority(); + expect(authority.capabilities).toContain("daimon-bridge"); + const releasedIdentity = { + architecture: "amd64", + asset: trustedMoltnetReleaseAsset(authority, "amd64").asset, + asset_sha256: trustedMoltnetReleaseAsset(authority, "amd64").asset_sha256, + capabilities: authority.capabilities, + release_version: authority.release_version, + source_revision: authority.source_revision, + version: "spawnfile.moltnet-release-identity.v1" + }; + + const thrown: unknown = await compileWith(daimonPlans, releasedIdentity).catch((error: unknown) => error); + + expect(thrown instanceof Error ? thrown.message : "").not.toContain("daimon-bridge"); + }); + it("leaves a pi-only Moltnet attachment on a pi-bridge release alone", async () => { const piPlans = moltnetWith([{ configPath: "/etc/spawnfile/moltnet/pi.json", networkId: "lab" }]); const thrown: unknown = await compileWith(piPlans, piBridgeRelease).catch((error: unknown) => error); diff --git a/src/compiler/containerArtifacts.ts b/src/compiler/containerArtifacts.ts index b0f34455..5460bbda 100644 --- a/src/compiler/containerArtifacts.ts +++ b/src/compiler/containerArtifacts.ts @@ -49,9 +49,10 @@ export interface ContainerArtifactOptions { * `agent_id` at strict decode, before `Validate()` is even reached. The * entrypoint launches `moltnet node &` and then `wait -n` * (`./containerEntrypointRender.ts`), so that exit tears the whole container - * down. Only a locally built Moltnet advertises `daimon-bridge` - * (`./localMoltnetAuthority.ts`); the pinned public authority is hard-narrowed - * to `["pi-bridge"]` (`./moltnetReleaseAuthority.ts`). + * down. Both a locally built Moltnet (`./localMoltnetAuthority.ts`) and, since + * the v0.1.18 pin, the published authority (`./moltnetReleaseAuthority.ts`) can + * advertise `daimon-bridge`; anything older, including every release through + * v0.1.17, advertises only `pi-bridge` and is still rejected here. * * This throws rather than warning, which is the opposite call from the other * unlowerable-declaration diagnostics in this compiler. Those keep working diff --git a/src/compiler/moltnetBinaries.ts b/src/compiler/moltnetBinaries.ts index 805630f1..f189a0d3 100644 --- a/src/compiler/moltnetBinaries.ts +++ b/src/compiler/moltnetBinaries.ts @@ -7,9 +7,11 @@ import { promisify } from "node:util"; import { ensureDirectory, fileExists } from "../filesystem/index.js"; import { SpawnfileError } from "../shared/index.js"; import { + parseMoltnetBridgeCapabilities, parseTrustedMoltnetReleaseAuthority, readTrustedMoltnetReleaseAuthority, trustedMoltnetReleaseAsset, + type MoltnetBridgeCapabilities, type MoltnetTargetArchitecture, type TrustedMoltnetReleaseAuthority } from "./moltnetReleaseAuthority.js"; @@ -37,7 +39,8 @@ export const MOLTNET_RELEASE_IDENTITY_VERSION = "spawnfile.moltnet-release-ident export const MOLTNET_RELEASE_STAMP_VERSION = "spawnfile.moltnet-release-stamp.v1" as const; export type { MoltnetTargetArchitecture } from "./moltnetReleaseAuthority.js"; -export type MoltnetBridgeCapabilities = readonly ["pi-bridge"] | readonly ["daimon-bridge", "pi-bridge"]; +/** Re-exported so existing importers keep one shape; defined in the lowest layer. */ +export type { MoltnetBridgeCapabilities } from "./moltnetReleaseAuthority.js"; interface MoltnetIdentityBase { readonly architecture: MoltnetTargetArchitecture; @@ -48,7 +51,7 @@ interface MoltnetIdentityBase { } export interface PublishedMoltnetReleaseIdentity extends MoltnetIdentityBase { - readonly capabilities: readonly ["pi-bridge"]; + readonly capabilities: MoltnetBridgeCapabilities; readonly release_version: string; readonly source_revision: string; } @@ -67,7 +70,15 @@ interface MoltnetReleaseStamp { readonly arch: MoltnetTargetArchitecture; readonly asset: string; readonly built_at: string; - readonly capabilities: readonly ["pi-bridge"]; + readonly capabilities: MoltnetBridgeCapabilities; + /** + * Kept as a required scalar, NOT derived from `capabilities`: both capability + * variants include `pi-bridge`, so this is invariant across the widening and + * deriving it would change the stamp format for no gain. The stamp format is a + * matched pair with the writers in `fixtures/support/trustedMoltnetRelease.ts` + * and `src/e2e/localMoltnetRelease.ts`; there is no `daimon_bridge` twin + * because that would be a second way to say what `capabilities` already says. + */ readonly pi_bridge: true; readonly sha256: string; readonly source_revision: string; @@ -173,9 +184,7 @@ const parseReleaseStamp = (raw: string, architecture: MoltnetTargetArchitecture) || value.asset !== createReleaseAssetName(architecture) || typeof value.built_at !== "string" || !Number.isFinite(Date.parse(value.built_at)) - || !Array.isArray(capabilities) - || capabilities.length !== 1 - || capabilities[0] !== "pi-bridge" + || parseMoltnetBridgeCapabilities(capabilities) === null || value.pi_bridge !== true || typeof value.sha256 !== "string" || !SHA256.test(value.sha256) @@ -235,7 +244,7 @@ const verifyReleaseIdentity = async ( architecture, asset, asset_sha256: `sha256:${sha256}`, - capabilities: Object.freeze(["pi-bridge"] as const), + capabilities: stamp.capabilities, release_version: stamp.version, source_revision: stamp.source_revision, version: MOLTNET_RELEASE_IDENTITY_VERSION @@ -409,7 +418,7 @@ export const stageMoltnetBinaries = async ( architecture, asset: trustedAsset.asset, asset_sha256: trustedAsset.asset_sha256, - capabilities: Object.freeze(["pi-bridge"] as const), + capabilities: authority.capabilities, release_version: authority.release_version, source_revision: authority.source_revision, version: MOLTNET_RELEASE_IDENTITY_VERSION diff --git a/src/compiler/moltnetReleaseAuthority.test.ts b/src/compiler/moltnetReleaseAuthority.test.ts index 82e2be61..79304dcf 100644 --- a/src/compiler/moltnetReleaseAuthority.test.ts +++ b/src/compiler/moltnetReleaseAuthority.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + MOLTNET_DUAL_BRIDGE_CAPABILITIES, + parseMoltnetBridgeCapabilities, parseTrustedMoltnetReleaseAuthority, readTrustedMoltnetReleaseAuthority, trustedMoltnetReleaseAsset @@ -11,17 +13,17 @@ describe("trusted Moltnet release authority", () => { const authority = await readTrustedMoltnetReleaseAuthority(); expect(authority).toMatchObject({ version: "spawnfile.moltnet-release-authority.v1", - release_version: "v0.1.14", - source_revision: "7baeb284ba0b1b5e454476141a557d68b5a4af0d", - capabilities: ["pi-bridge"] + release_version: "v0.1.18", + source_revision: "988c5284f45705beb3bf59a4a4c0008605ce609e", + capabilities: ["daimon-bridge", "pi-bridge"] }); expect(trustedMoltnetReleaseAsset(authority, "arm64")).toMatchObject({ asset: "moltnet_linux_arm64.tar.gz", - asset_sha256: `sha256:${"3d46ad047496dd32a9ad41901e7ae1f10a25e9a21f3b29d3b6ccd2cff62fa58f"}` + asset_sha256: `sha256:${"40c1fae1c687a59a9e6d804ac28d00e925f318bd4e922accdca4402ceab88548"}` }); expect(trustedMoltnetReleaseAsset(authority, "amd64")).toMatchObject({ asset: "moltnet_linux_amd64.tar.gz", - asset_sha256: `sha256:${"a2e7a0acd44ab548a81d99e51401ebdee9f50a539b24c9450fc12d8e9218e5f6"}` + asset_sha256: `sha256:${"92d356cd33841e89b6bc56c6e8c2c37d987124f35c896d033e8766eab09da2c5"}` }); }); @@ -35,4 +37,46 @@ describe("trusted Moltnet release authority", () => { /authority is invalid/u ); }); + + /** + * The capability list is a UNION, never a replacement. moltnet v0.1.18 + * advertises both bridges, but every older pi-only release must keep pinning + * cleanly — widening that rejects the old shape would strand every existing + * pin. + */ + it("still accepts a pi-only published release after the daimon widening", async () => { + const authority = await readTrustedMoltnetReleaseAuthority(); + const piOnly = parseTrustedMoltnetReleaseAuthority({ + ...authority, + release_version: "v0.1.14", + source_revision: "7baeb284ba0b1b5e454476141a557d68b5a4af0d", + capabilities: ["pi-bridge"] + }); + expect(piOnly.capabilities).toEqual(["pi-bridge"]); + }); + + /** + * CANONICAL ORDERING. Several consumers compare this list by exact equality + * rather than as a set (`upReceipt.ts`, `localMoltnetAuthority.ts`), and the + * local builder writes `daimon-bridge` first. A reordered-but-equivalent list + * must be rejected rather than quietly accepted, or the two producers drift + * and a valid release starts failing to pin. + */ + it("pins one canonical capability ordering and rejects any other", async () => { + const authority = await readTrustedMoltnetReleaseAuthority(); + expect(MOLTNET_DUAL_BRIDGE_CAPABILITIES).toEqual(["daimon-bridge", "pi-bridge"]); + expect(authority.capabilities).toEqual([...MOLTNET_DUAL_BRIDGE_CAPABILITIES]); + expect(parseMoltnetBridgeCapabilities(["daimon-bridge", "pi-bridge"])).toEqual(["daimon-bridge", "pi-bridge"]); + for (const rejected of [ + ["pi-bridge", "daimon-bridge"], + ["daimon-bridge"], + ["pi-bridge", "pi-bridge"], + ["daimon-bridge", "pi-bridge", "extra"], + [], + "pi-bridge" + ]) { + expect(parseMoltnetBridgeCapabilities(rejected)).toBeNull(); + expect(() => parseTrustedMoltnetReleaseAuthority({ ...authority, capabilities: rejected })).toThrow(); + } + }); }); diff --git a/src/compiler/moltnetReleaseAuthority.ts b/src/compiler/moltnetReleaseAuthority.ts index 341b6681..3d5b0ec7 100644 --- a/src/compiler/moltnetReleaseAuthority.ts +++ b/src/compiler/moltnetReleaseAuthority.ts @@ -6,6 +6,41 @@ export const MOLTNET_RELEASE_AUTHORITY_VERSION = "spawnfile.moltnet-release-authority.v1" as const; export type MoltnetTargetArchitecture = "amd64" | "arm64"; +/** + * The bridge capabilities a Moltnet build can advertise. + * + * CANONICAL ORDERING: `daimon-bridge` before `pi-bridge`. Several places + * compare this list by exact equality (`upReceipt.ts`, `localMoltnetAuthority.ts`) + * rather than as a set, so a differently ordered but equivalent list is + * rejected as a different release. The local builder + * (`scripts/build-local-moltnet.mjs`) already writes this order; every producer + * must match it, and `moltnetReleaseAuthority.test.ts` asserts it. + * + * This is a UNION, never a replacement: an older pi-only release must keep + * pinning cleanly after a dual-capability one is published. + */ +export const MOLTNET_PI_ONLY_CAPABILITIES = ["pi-bridge"] as const; +export const MOLTNET_DUAL_BRIDGE_CAPABILITIES = ["daimon-bridge", "pi-bridge"] as const; +export type MoltnetBridgeCapabilities = + | typeof MOLTNET_PI_ONLY_CAPABILITIES + | typeof MOLTNET_DUAL_BRIDGE_CAPABILITIES; + +/** + * The single runtime check for an advertised capability list. Every producer + * and parser funnels through this so the type and the runtime check can never + * drift apart — widening one without the other is precisely how a release + * becomes typecheck-green and runtime-rejected. + */ +export const parseMoltnetBridgeCapabilities = ( + value: unknown +): MoltnetBridgeCapabilities | null => { + if (!Array.isArray(value)) return null; + const joined = value.join("\0"); + if (joined === MOLTNET_PI_ONLY_CAPABILITIES.join("\0")) return MOLTNET_PI_ONLY_CAPABILITIES; + if (joined === MOLTNET_DUAL_BRIDGE_CAPABILITIES.join("\0")) return MOLTNET_DUAL_BRIDGE_CAPABILITIES; + return null; +}; + export interface TrustedMoltnetReleaseAsset { readonly architecture: MoltnetTargetArchitecture; readonly asset: string; @@ -16,7 +51,7 @@ export interface TrustedMoltnetReleaseAuthority { readonly version: typeof MOLTNET_RELEASE_AUTHORITY_VERSION; readonly release_version: string; readonly source_revision: string; - readonly capabilities: readonly ["pi-bridge"]; + readonly capabilities: MoltnetBridgeCapabilities; readonly assets: readonly TrustedMoltnetReleaseAsset[]; } @@ -42,9 +77,7 @@ export const parseTrustedMoltnetReleaseAuthority = ( || !VERSION.test(value.release_version) || typeof value.source_revision !== "string" || !REVISION.test(value.source_revision) - || !Array.isArray(value.capabilities) - || value.capabilities.length !== 1 - || value.capabilities[0] !== "pi-bridge" + || parseMoltnetBridgeCapabilities(value.capabilities) === null || !Array.isArray(value.assets) || value.assets.length !== 2) return fail(); const assets: TrustedMoltnetReleaseAsset[] = []; @@ -69,7 +102,7 @@ export const parseTrustedMoltnetReleaseAuthority = ( version: MOLTNET_RELEASE_AUTHORITY_VERSION, release_version: value.release_version, source_revision: value.source_revision, - capabilities: Object.freeze(["pi-bridge"] as const), + capabilities: parseMoltnetBridgeCapabilities(value.capabilities)!, assets: Object.freeze(assets) }); }; diff --git a/src/compiler/upReceipt.test.ts b/src/compiler/upReceipt.test.ts index 07558e0b..0fffe778 100644 --- a/src/compiler/upReceipt.test.ts +++ b/src/compiler/upReceipt.test.ts @@ -284,6 +284,34 @@ describe("buildUpReceipt", () => { }); }); + /** + * A PUBLISHED release now advertises both bridges (moltnet v0.1.18). The + * receipt used to pick its branch by capability COUNT, so a two-capability + * published release fell into the local-development branch and was rejected + * for lacking a `development` marker it must never carry. Provenance, not + * count, decides the branch. + */ + it("accepts a published release advertising both bridges without demanding development provenance", async () => { + const fixtureDirectory = await createSingleAgentFixture(); + const outputDirectory = await createTempDirectory("spawnfile-up-receipt-compiled-"); + const recordPath = await writeDeploymentRecord(outputDirectory, createRecord()); + const upResult = createUpResult(outputDirectory, recordPath); + upResult.report.container!.moltnet!.release = { + ...upResult.report.container!.moltnet!.release!, + capabilities: ["daimon-bridge", "pi-bridge"], + release_version: "v0.1.18" + } as never; + + const receipt = await buildUpReceipt(fixtureDirectory, upResult); + + expect(receipt.moltnet_release).toMatchObject({ + capabilities: ["daimon-bridge", "pi-bridge"], + release_version: "v0.1.18" + }); + // A published release must never acquire local-development provenance. + expect(receipt.moltnet_release).not.toHaveProperty("development"); + }); + it("discloses a scripted pi engine per agent, derived from the compile report's engine_by_node_id", async () => { const fixtureDirectory = await createSingleAgentFixture(); const outputDirectory = await createTempDirectory("spawnfile-up-receipt-compiled-"); diff --git a/src/compiler/upReceipt.ts b/src/compiler/upReceipt.ts index ed2979cc..c03cab85 100644 --- a/src/compiler/upReceipt.ts +++ b/src/compiler/upReceipt.ts @@ -51,15 +51,33 @@ const createReceiptMoltnetIdentity = ( : never ) => { if (!release) return undefined; - if (release.capabilities.length === 1) { - if (release.capabilities[0] !== "pi-bridge" || !release.release_version || !release.source_revision) { + // Discriminate on PROVENANCE, never on capability count. A published release + // now advertises `daimon-bridge` too (moltnet v0.1.18), so `length === 1` would + // route it into the local-development branch and reject it for lacking a + // `development` marker it is never supposed to have. + // + // The canonical list lives in `moltnetReleaseAuthority.ts`, but the H2 boundary + // (`../deployment/organizationHandoffTypes.test.ts`) forbids this file from + // importing any `moltnet*` module, so the ordering is compared inline here the + // same way the local branch below already compares it. `upReceiptTypes.ts`'s + // schema is the authority that rejects any other list on parse. + const joined = release.capabilities.join("\0"); + const receiptCapabilities: ["daimon-bridge", "pi-bridge"] | ["pi-bridge"] | null = + joined === "daimon-bridge\0pi-bridge" + ? ["daimon-bridge", "pi-bridge"] + : joined === "pi-bridge" ? ["pi-bridge"] : null; + if (receiptCapabilities === null) { + throw new SpawnfileError("runtime_error", "Moltnet receipt advertises unknown bridge capabilities"); + } + if (!release.development) { + if (!release.release_version || !release.source_revision) { throw new SpawnfileError("runtime_error", "Published Moltnet receipt lacks its pinned source identity"); } return { architecture: release.architecture, asset: release.asset, asset_sha256: release.asset_sha256, - capabilities: ["pi-bridge"] as ["pi-bridge"], + capabilities: receiptCapabilities, release_version: release.release_version, source_revision: release.source_revision, version: release.version @@ -67,7 +85,6 @@ const createReceiptMoltnetIdentity = ( } if ( release.capabilities.join("\0") !== "daimon-bridge\0pi-bridge" || - !release.development || !release.source_sha256 ) { throw new SpawnfileError("runtime_error", "Local Moltnet receipt lacks its development provenance"); diff --git a/src/deployment/upReceiptTypes.ts b/src/deployment/upReceiptTypes.ts index 1ab28199..4cfea10a 100644 --- a/src/deployment/upReceiptTypes.ts +++ b/src/deployment/upReceiptTypes.ts @@ -67,7 +67,12 @@ const publishedMoltnetReleaseIdentitySchema = z.object({ architecture: z.union([z.literal("amd64"), z.literal("arm64")]), asset: z.string().regex(/^moltnet_linux_(amd64|arm64)\.tar\.gz$/u), asset_sha256: z.string().regex(/^sha256:[a-f0-9]{64}$/u), - capabilities: z.tuple([z.literal("pi-bridge")]), + // Union, not replacement: moltnet v0.1.18 publishes both bridges, and every + // older pi-only release must still validate. + capabilities: z.union([ + z.tuple([z.literal("pi-bridge")]), + z.tuple([z.literal("daimon-bridge"), z.literal("pi-bridge")]) + ]), release_version: z.string().regex(/^v?\d+\.\d+\.\d+(?:-\d+-g[a-f0-9]{7,40})?$/u), source_revision: z.string().regex(/^[a-f0-9]{40}$/u), version: z.literal("spawnfile.moltnet-release-identity.v1") diff --git a/src/e2e/localMoltnetRelease.ts b/src/e2e/localMoltnetRelease.ts index 669adb43..f9809dc2 100644 --- a/src/e2e/localMoltnetRelease.ts +++ b/src/e2e/localMoltnetRelease.ts @@ -3,6 +3,7 @@ import { readFile as readFileBinary } from "node:fs/promises"; import path from "node:path"; import { fileExists, readUtf8File } from "../filesystem/index.js"; +import { parseMoltnetBridgeCapabilities } from "../compiler/moltnetReleaseAuthority.js"; import { SpawnfileError } from "../shared/index.js"; import { parseTrustedMoltnetReleaseAuthority, @@ -120,9 +121,7 @@ export const decideLocalMoltnetStaging = ( || input.stamp.asset !== input.assetName || typeof input.stamp.built_at !== "string" || !Number.isFinite(Date.parse(input.stamp.built_at)) - || !Array.isArray(capabilities) - || capabilities.length !== 1 - || capabilities[0] !== "pi-bridge" + || parseMoltnetBridgeCapabilities(capabilities) === null || input.stamp.pi_bridge !== true || typeof input.stamp.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(input.stamp.sha256) From 430cc6f8a41f92767fe54090c45ebf1deb26e75f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:01:49 +0200 Subject: [PATCH 27/34] ci: test only spawnfile, dropping sibling-repo suites and cross-repo image builds --- .github/workflows/test.yml | 160 ------------------------------------- 1 file changed, 160 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6974fe2f..a212de36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,36 +42,6 @@ jobs: - name: Build run: npm run build - - name: Check out Daimon provenance fixture - uses: actions/checkout@v4 - with: - repository: noopolis/daimon - path: daimon-fixture - - - name: Verify Git-free offline linux/amd64 source build - env: - SPAWNFILE_TEST_DAIMON_SOURCE: ${{ github.workspace }}/daimon-fixture - run: npm run test:source-provenance-docker - - - name: Remove Daimon provenance fixture - if: always() - run: rm -rf daimon-fixture - - - name: Check out Moltnet provenance fixture - uses: actions/checkout@v4 - with: - repository: noopolis/moltnet - path: moltnet-fixture - - - name: Verify Git-free offline linux/amd64 Moltnet build - env: - SPAWNFILE_TEST_MOLTNET_SOURCE: ${{ github.workspace }}/moltnet-fixture - run: npm run test:moltnet-source-provenance-docker - - - name: Remove Moltnet provenance fixture - if: always() - run: rm -rf -- moltnet-fixture - - name: Verify native helper syscalls run: node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.test.mjs @@ -83,133 +53,3 @@ jobs: - name: Coverage run: npm run coverage - - simfile: - runs-on: ubuntu-latest - steps: - - name: Check out Simfile - uses: actions/checkout@v4 - with: - repository: noopolis/simfile - path: ecosystem/simfile - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: ecosystem/simfile/package-lock.json - - - name: Install dependencies - working-directory: ecosystem/simfile - run: npm ci - - - name: Typecheck - working-directory: ecosystem/simfile - run: npm run typecheck - - - name: Test - working-directory: ecosystem/simfile - run: npm test - - daimon: - runs-on: ubuntu-latest - steps: - - name: Check out Daimon - uses: actions/checkout@v4 - with: - repository: noopolis/daimon - path: ecosystem/daimon - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: ecosystem/daimon/package-lock.json - - - name: Install dependencies - working-directory: ecosystem/daimon - run: npm ci - - - name: Typecheck - working-directory: ecosystem/daimon - run: npm run typecheck - - - name: Test - working-directory: ecosystem/daimon - run: npm test - - mneme: - runs-on: ubuntu-latest - steps: - - name: Check out Mneme - uses: actions/checkout@v4 - with: - repository: noopolis/mneme - path: ecosystem/mneme - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: ecosystem/mneme/package-lock.json - - - name: Install dependencies - working-directory: ecosystem/mneme - run: npm ci - - - name: Typecheck - working-directory: ecosystem/mneme - run: npm run typecheck - - - name: Test - working-directory: ecosystem/mneme - run: npm test - - stele: - runs-on: ubuntu-latest - steps: - - name: Check out Stele - uses: actions/checkout@v4 - with: - repository: noopolis/stele - path: ecosystem/stele - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: ecosystem/stele/package-lock.json - - - name: Install dependencies - working-directory: ecosystem/stele - run: npm ci - - - name: Typecheck - working-directory: ecosystem/stele - run: npm run typecheck - - - name: Test - working-directory: ecosystem/stele - run: npm test - - moltnet: - runs-on: ubuntu-latest - steps: - - name: Check out Moltnet - uses: actions/checkout@v4 - with: - repository: noopolis/moltnet - path: ecosystem/moltnet - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: ecosystem/moltnet/go.mod - - - name: Test - working-directory: ecosystem/moltnet - run: go test ./... From 7af72a4d24f0778d4cc1bbf16cbcdd20994af825 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:02:42 +0200 Subject: [PATCH 28/34] ci: bound every job with a timeout --- .github/workflows/deploy-website.yml | 1 + .github/workflows/publish.yml | 1 + .github/workflows/runtime-images.yml | 1 + .github/workflows/test.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index e85290cf..89d5f039 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -9,6 +9,7 @@ on: jobs: deploy: + timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7843d5c..a9dbd474 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,7 @@ concurrency: jobs: publish: + timeout-minutes: 20 runs-on: ubuntu-latest env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} diff --git a/.github/workflows/runtime-images.yml b/.github/workflows/runtime-images.yml index affdd6a6..5b1a58a4 100644 --- a/.github/workflows/runtime-images.yml +++ b/.github/workflows/runtime-images.yml @@ -40,6 +40,7 @@ concurrency: jobs: build: + timeout-minutes: 30 runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a212de36..85f5ac41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,7 @@ permissions: jobs: root: + timeout-minutes: 30 runs-on: ubuntu-latest steps: - name: Check out Spawnfile From a25a8edfdb79502708e77d85b9bc497abf56f472 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:20:01 +0200 Subject: [PATCH 29/34] ci: name every job after what it verifies --- .github/workflows/deploy-website.yml | 1 + .github/workflows/publish.yml | 1 + .github/workflows/runtime-images.yml | 1 + .github/workflows/test.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 89d5f039..078de4df 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -9,6 +9,7 @@ on: jobs: deploy: + name: deploy spawnfile.com timeout-minutes: 15 runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a9dbd474..3f767909 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,7 @@ concurrency: jobs: publish: + name: publish spawnfile to npm timeout-minutes: 20 runs-on: ubuntu-latest env: diff --git a/.github/workflows/runtime-images.yml b/.github/workflows/runtime-images.yml index 5b1a58a4..6cfe9a6c 100644 --- a/.github/workflows/runtime-images.yml +++ b/.github/workflows/runtime-images.yml @@ -40,6 +40,7 @@ concurrency: jobs: build: + name: ${{ matrix.runtime }} runtime image timeout-minutes: 30 runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85f5ac41..6940ffe4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,7 @@ permissions: jobs: root: + name: compiler + runtime tests timeout-minutes: 30 runs-on: ubuntu-latest steps: From ca0431270c91496d5704f3fc0b959962fef91017 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:24:30 +0200 Subject: [PATCH 30/34] fix(cli): report the cause when a product-state clone fails --- src/cli/productStateCloneCommand.test.ts | 2 +- src/cli/productStateCloneCommand.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/productStateCloneCommand.test.ts b/src/cli/productStateCloneCommand.test.ts index 443329df..94a3f060 100644 --- a/src/cli/productStateCloneCommand.test.ts +++ b/src/cli/productStateCloneCommand.test.ts @@ -15,7 +15,7 @@ describe("product-state clone CLI", () => { const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); expect(isProductStateCloneInvocation(["product-state", "clone", request])).toBe(true); expect(isProductStateCloneInvocation(["compile"])).toBe(false); await expect(runProductStateCloneCommand(["product-state", "clone", request])).resolves.toBe(0); expect(clone).toHaveBeenCalledWith({ authorityReceiptPath: "/authority", dockerCommand: "docker", destination: "/candidate", proofPath: "/proof", receiptPath: "/receipt", candidateRunId: "candidate" }); expect(stdout).toHaveBeenCalled(); - await expect(runProductStateCloneCommand(["product-state", "clone"])).resolves.toBe(1); expect(stderr).toHaveBeenCalledWith("error: Product-state clone failed\n"); + await expect(runProductStateCloneCommand(["product-state", "clone"])).resolves.toBe(1); expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/^error: Product-state clone failed: /u)); } finally { await rm(root, { recursive: true, force: true }); } }); }); diff --git a/src/cli/productStateCloneCommand.ts b/src/cli/productStateCloneCommand.ts index 17f971eb..67dc3f85 100644 --- a/src/cli/productStateCloneCommand.ts +++ b/src/cli/productStateCloneCommand.ts @@ -16,5 +16,11 @@ export const runProductStateCloneCommand = async (argv: readonly string[]): Prom } const request = requestSchema.parse(JSON.parse(bytes.toString("utf8"))); process.stdout.write(`${JSON.stringify(await runProductStateCloneWorkflow({ authorityReceiptPath: request.authority_receipt_path, dockerCommand: request.docker_command, destination: request.destination, proofPath: request.proof_path, receiptPath: request.receipt_path, candidateRunId: request.candidate_run_id }))}\n`); return 0; - } catch { process.stderr.write("error: Product-state clone failed\n"); return 1; } + } catch (error) { + // Carry the cause. A bare "failed" is undiagnosable in CI, where this runs + // against a different Docker and architecture than any developer machine. + const detail = error instanceof Error ? error.message : String(error); + process.stderr.write(`error: Product-state clone failed: ${detail}\n`); + return 1; + } }; From 0124ef5169691adadfa18a0d03f9db140d911493 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:28:14 +0200 Subject: [PATCH 31/34] docs: require every change to land through a pull request --- AGENTS.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cba7de42..1f2a33f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,22 @@ This repository is the reference implementation of the Spawnfile v0.1 compiler. - The CLI should stay thin. Business logic belongs in compiler modules, not command handlers. - The compiler should operate on resolved graph data, not raw YAML, after load and validation. -## Commits +## Branches and pull requests -- Use conventional commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). -- Never add co-author attributions, sign-off lines, or AI credit to commits. No `Co-Authored-By`, no `Signed-off-by`, no mentions of AI tools in commit messages since its obvious. +**Never commit to `main`.** Every change lands through a pull request, without +exception — including one-line fixes, CI configuration, documentation, and +version bumps. Work on a branch, push it, open the PR, and let CI run. + +Direct commits to `main` bypass the checks that catch what local runs do not. +A zero-byte receipt store, a package that ships without its native binary, and +a two-week-red pipeline all reached `main` in this ecosystem while every local +gate was green — CI found them the first time it ran over the code. + +- Branch names describe the change: `feat/…`, `fix/…`, `ci/…`, `docs/…`. +- Commit messages are conventional and single-line (`feat:`, `fix:`, `docs:`, + `ci:`, `chore:`, `refactor:`, `test:`). +- Never add co-author lines, sign-offs, or AI attributions. +- Commit as you go rather than in one batch at the end, so history shows how + the work progressed. +- Merge with a merge commit rather than a squash when the individual commits + carry meaning; squashing collapses that history irreversibly. From df3594db9d7791b870b21aa4283b12d7e23fea11 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:33:43 +0200 Subject: [PATCH 32/34] ci: run the product-state preseed check as root so it can read the volume --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6940ffe4..565c32ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,8 +47,13 @@ jobs: - name: Verify native helper syscalls run: node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.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 + # root-owned 0700, so this needs root — it passes unprivileged on macOS + # only because Docker runs in a VM there. - name: Verify named-volume product-state preseed - run: npm run test:product-state-volume + run: sudo -E env "PATH=$PATH" npm run test:product-state-volume - name: Boundary tests run: npm run test:boundaries From 794759ff34ee826b9ce949da173bc9faa0fd72d6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:34:04 +0200 Subject: [PATCH 33/34] ci: drop the daimon runtime image from the matrix it cannot build --- .github/workflows/runtime-images.yml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/runtime-images.yml b/.github/workflows/runtime-images.yml index 6cfe9a6c..89d68ade 100644 --- a/.github/workflows/runtime-images.yml +++ b/.github/workflows/runtime-images.yml @@ -59,14 +59,22 @@ jobs: context: runtime-images/picoclaw build_args: | PICOCLAW_VERSION=v0.3.1 - - runtime: daimon - image: noopolis/spawnfile-runtime-daimon - version: 0.1.2 - context: runtime-images/daimon - build_args: | - DAIMON_VERSION=0.1.2 - MNEME_VERSION=0.1.1 - PI_VERSION=0.79.10 + # The Daimon runtime image is deliberately absent from this matrix. + # It cannot be built by a plain `docker build` of a context: the + # Dockerfile opens with `FROM daimon_package` and needs a + # `--build-context` carrying a packed Daimon tarball, plus a + # capability receipt, three content digests, and eight pinned CLI + # artifact URLs and hashes. It also hard-pins linux/amd64, which + # contradicts this job's two-platform build. The entry that used to + # sit here passed DAIMON_VERSION/MNEME_VERSION/PI_VERSION — three + # args the Dockerfile does not declare — so it had never produced an + # image, only a red check. + # + # `npm run build:local-daimon` (scripts/build-local-daimon-runtime.mjs) + # assembles all of that and is the supported path today. Publishing + # from CI means teaching this workflow to call that script with a + # non-loopback registry target, which `resolveLocalImageTag` currently + # refuses by design. steps: - name: Check out uses: actions/checkout@v4 From 799f01bfd9f7908df3087a04ccecd6c947e7d345 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 16:03:07 +0200 Subject: [PATCH 34/34] test: exercise the Daimon ownership guard symlink rejection in a container it owns --- ...tainerDaimonUidEntrypointLifecycle.test.ts | 111 ++++++++++++------ 1 file changed, 76 insertions(+), 35 deletions(-) diff --git a/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts b/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts index ee96f204..e6e3f137 100644 --- a/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts +++ b/src/compiler/containerDaimonUidEntrypointLifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { execFile as execFileCallback, spawnSync } from "node:child_process"; -import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -21,6 +21,12 @@ import { const execFile = promisify(execFileCallback); const authorizedUid = 2000; +// Every directory from the private state root down to `target`, so a test can create each +// one with the root-owned 0711 preimage the ownership guard demands. +const privateStateAncestors = (target: string): string[] => + path.posix.relative("/var/lib", target).split("/") + .map((_, index, segments) => path.posix.join("/var/lib", ...segments.slice(0, index + 1))); + const daimonPlan: RuntimeTargetPlan = { engineByNodeId: { "agent:AGY": "agy", "agent:Codex One": "codex", "agent:Grok Two": "grok" }, envFiles: [], id: "daimon-organization", @@ -358,39 +364,74 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ } }, 60_000); + // The guard hardens the fixed /var and /var/lib ancestors and the private state root + // before it ever opens a compiler-authored state root, and it walks every absolute path + // segment by segment from "/" through /proc/self/fd. Both facts make the host filesystem + // the wrong place to exercise the hostile-ancestor rejection: on macOS every open fails + // because /proc does not exist, and on unprivileged Linux the /var fchmod raises EPERM, + // so the assertion below would pass or explode for reasons unrelated to the symlink. + // Running inside a throwaway Linux container gives the test a filesystem it owns from / + // down, so the rejection it asserts is caused by the symlink the test itself planted. it("rejects an ancestor symlink and ignores run-env roots before a restart can reach an external entrypoint", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-daimon-root-link-")); - const externalRoot = path.join(directory, "opt", "spawnfile", "root"); - const protectedEntrypoint = path.join(externalRoot, "entrypoint.sh"); - const hostileParent = path.join(directory, "compiled"); - const instanceRoot = path.join(hostileParent, "instance"); - const runEnvironment = path.join(directory, "run.env"); - const wrapper = path.join(directory, "daimon-uid-entrypoint.sh"); - try { - await mkdir(externalRoot, { recursive: true }); - await writeFile(protectedEntrypoint, "trusted root entrypoint\n", "utf8"); - await symlink(path.join(directory, "opt", "spawnfile", "root"), hostileParent); - await writeFile(runEnvironment, "SPAWNFILE_DAIMON_WRITABLE_ROOTS=/opt/spawnfile/root\n", "utf8"); - const rendered = renderDaimonUidEntrypoint([{ - ...daimonPlan, - instancePaths: { - ...daimonPlan.instancePaths, - instanceRoot, - workspacePath: path.join(instanceRoot, "workspace") - } - }]); - await writeFile(wrapper, rendered, "utf8"); - for (const _restart of [0, 1]) { - const result = spawnSync("bash", ["-c", 'set -a; . "$1"; set +a; exec bash "$2"', "bash", runEnvironment, wrapper], { - env: process.env - }); - expect(result.status).not.toBe(0); - expect(Buffer.from(result.stderr).toString("utf8")).toContain("symbolic-link"); + const testRoot = "/spawnfile-daimon-root-link"; + const externalRoot = `${testRoot}/opt/spawnfile/root`; + const protectedEntrypoint = `${externalRoot}/entrypoint.sh`; + const hostileParent = `${testRoot}/compiled`; + const instanceRoot = `${hostileParent}/instance`; + const wrapper = `${testRoot}/daimon-uid-entrypoint.sh`; + const runEnvironment = `${testRoot}/run.env`; + const configPath = daimonPlan.instancePaths.configPath; + const rejection = "Daimon ownership guard: root has a symbolic-link or unavailable path component"; + const plan: RuntimeTargetPlan = { + ...daimonPlan, + instancePaths: { + ...daimonPlan.instancePaths, + instanceRoot, + workspacePath: `${instanceRoot}/workspace` } - expect(await readFile(protectedEntrypoint, "utf8")).toBe("trusted root entrypoint\n"); - expect((await lstat(externalRoot)).isDirectory()).toBe(true); - } finally { - await rm(directory, { force: true, recursive: true }); - } - }); + }; + const stateRoots = resolveDaimonUidEntrypointStateRoots([plan]); + expect(stateRoots).toEqual([`${instanceRoot}/runtime-homes`, `${instanceRoot}/workspace`]); + const rendered = renderDaimonUidEntrypoint([plan]); + expect(rendered).not.toContain("SPAWNFILE_DAIMON_WRITABLE_ROOTS"); + const { stdout } = await execFile("docker", [ + "run", "--rm", + "--env", `SPAWNFILE_TEST_WRAPPER=${Buffer.from(rendered, "utf8").toString("base64")}`, + "node:24-bookworm-slim", "bash", "-c", + [ + "set -eu", + // Behind the symlink the state roots resolve to real directories, so the guard can + // only fail for having refused to follow the symlink, never for a missing path. + `install -d -o root -g root -m 755 '${externalRoot}' ${stateRoots.map((root) => `'${root.replace(hostileParent, externalRoot)}'`).join(" ")}`, + `printf 'trusted root entrypoint\\n' > '${protectedEntrypoint}'`, + `ln -s '${externalRoot}' '${hostileParent}'`, + // Compiler-authored private state the guard secures before any state root. + ...privateStateAncestors(path.posix.dirname(configPath)) + .map((directory) => `install -d -o root -g root -m 711 '${directory}'`), + `printf '{}\\n' > '${configPath}'`, + `printf %s "$SPAWNFILE_TEST_WRAPPER" | base64 -d > '${wrapper}'`, + `printf 'SPAWNFILE_DAIMON_WRITABLE_ROOTS=/opt/spawnfile/root\\n' > '${runEnvironment}'`, + "for restart in 1 2; do", + " set +e", + ` guard_stderr=$( ( set -a; . '${runEnvironment}'; set +a; exec bash '${wrapper}' ) 2>&1 >/dev/null )`, + " guard_status=$?", + " set -e", + ` printf 'restart=%s status=%s stderr=%s\\n' "$restart" "$guard_status" "$guard_stderr"`, + "done", + `printf 'entrypoint=%s\\n' "$(cat '${protectedEntrypoint}')"`, + `printf 'external-root=%s\\n' "$(stat -c %F '${externalRoot}')"`, + ...stateRoots.map((root) => { + const behindLink = root.replace(hostileParent, externalRoot); + return `printf '%s=%s\\n' '${path.posix.basename(root)}' "$(stat -c '%u:%g:%a' '${behindLink}')"`; + }) + ].join("\n") + ], { timeout: 60_000 }); + expect(stdout).toContain(`restart=1 status=1 stderr=${rejection}\n`); + expect(stdout).toContain(`restart=2 status=1 stderr=${rejection}\n`); + expect(stdout).toContain("entrypoint=trusted root entrypoint\n"); + expect(stdout).toContain("external-root=directory\n"); + // Following the symlink would have handed the protected tree to the runtime UID. + expect(stdout).toContain("runtime-homes=0:0:755\n"); + expect(stdout).toContain("workspace=0:0:755\n"); + }, 60_000); });

X%yea=wzhV_?C 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 OO8kqIQ9M0P>;D6e3=+8j literal 0 HcmV?d00001 diff --git a/src/deployment/native/artifacts/rename-noreplace-arm64.provenance.json b/src/deployment/native/artifacts/rename-noreplace-arm64.provenance.json new file mode 100644 index 00000000..358ba184 --- /dev/null +++ b/src/deployment/native/artifacts/rename-noreplace-arm64.provenance.json @@ -0,0 +1 @@ +{"version":"spawnfile.rename-noreplace-build.v1","architecture":"arm64","binary_sha256":"sha256:fb1f0d24fe7dfc4db18e972b9d60d839bf11ed82c78bf89cf6eca3f21502529f","builder_image":"gcc:14.2.0@sha256:b99b86a28812b1e6453a231a947dc43d76fe192788a12f344a9b568bf9f5d24c","compiler":"gcc:14.2.0","source_sha256":"sha256:64458e923931f7ca4df9ac2d19fd0361093e985ab3fbd8bf422ceb31a60b0e79","target":"linux/arm64"} diff --git a/src/deployment/native/artifacts/rename-noreplace-x64 b/src/deployment/native/artifacts/rename-noreplace-x64 new file mode 100755 index 0000000000000000000000000000000000000000..a53019d1102e81f47d95c08a5478feb4f6ee5a90 GIT binary patch literal 707168 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