From a3145831b6ec8cc7f43eb4a571998dce22cf3e22 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Mon, 24 Aug 2026 00:07:21 +0200 Subject: [PATCH 1/7] feat(compose): run linked organizations through spawnfile --- .gitignore | 2 + AGENTS.md | 7 +- LICENSE | 21 + PLAN.md | 446 ++++++++++++++++++ PLAN_REVIEW.md | 155 ++++++ README.md | 185 ++++++-- docs/DESIGN.md | 6 + docs/RESEARCH.md | 2 +- docs/SITE_DESIGN.md | 37 +- docs/SYSTEMS_VIEW.md | 5 + docs/VIEW_DESIGN.md | 4 + docs/VIEW_STYLEGUIDE.md | 6 +- examples/AGENTS.md | 13 + examples/CLAUDE.md | 1 + examples/composed-development/AGENTS.md | 19 + examples/composed-development/CLAUDE.md | 1 + examples/composed-development/README.md | 77 +++ examples/composed-development/Simfile | 26 + .../composed-development/binding-world.mjs | 108 +++++ examples/composed-development/binding.mjs | 129 +++++ .../composed-development/harness/AGENTS.md | 5 + .../composed-development/harness/CLAUDE.md | 1 + .../harness/scripted-engine.mjs | 3 + examples/composed-development/org/AGENTS.md | 8 + examples/composed-development/org/CLAUDE.md | 1 + examples/composed-development/org/Spawnfile | 15 + examples/composed-development/org/TEAM.md | 5 + .../org/agents/smoke/AGENTS.md | 5 + .../org/agents/smoke/CLAUDE.md | 1 + .../org/agents/smoke/Spawnfile | 14 + examples/composed-development/world/AGENTS.md | 11 + examples/composed-development/world/CLAUDE.md | 1 + .../composed-development/world/composer.mjs | 182 +++++++ .../composed-development/world/evidence.mjs | 77 +++ .../composed-development/world/provider.mjs | 58 +++ .../composed-development/world/surface.mjs | 25 + examples/jungian-dialogue/AGENTS.md | 14 + examples/jungian-dialogue/CLAUDE.md | 1 + examples/jungian-dialogue/README.md | 94 ++++ examples/jungian-dialogue/Simfile | 34 ++ examples/jungian-dialogue/binding-world.mjs | 112 +++++ examples/jungian-dialogue/binding.mjs | 133 ++++++ examples/jungian-dialogue/harness/AGENTS.md | 9 + examples/jungian-dialogue/harness/CLAUDE.md | 1 + .../harness/jungian-engine.mjs | 112 +++++ examples/jungian-dialogue/org/AGENTS.md | 7 + examples/jungian-dialogue/org/CLAUDE.md | 1 + examples/jungian-dialogue/org/Spawnfile | 44 ++ examples/jungian-dialogue/org/TEAM.md | 14 + .../org/agents/analyst/AGENTS.md | 8 + .../org/agents/analyst/CLAUDE.md | 1 + .../org/agents/analyst/Spawnfile | 27 ++ .../org/agents/daimon/AGENTS.md | 9 + .../org/agents/daimon/CLAUDE.md | 1 + .../org/agents/daimon/Spawnfile | 22 + examples/jungian-dialogue/world/AGENTS.md | 13 + examples/jungian-dialogue/world/CLAUDE.md | 1 + examples/jungian-dialogue/world/composer.mjs | 167 +++++++ examples/jungian-dialogue/world/evidence.mjs | 66 +++ examples/jungian-dialogue/world/provider.mjs | 59 +++ examples/jungian-dialogue/world/surface.mjs | 24 + fixtures/e2e/autonomous-office-sim/TEAM.md | 6 +- .../jungian-daimon-org-golden/manifest.json | 2 +- .../spawnfile-report.json | 28 +- fixtures/sims/README.md | 20 +- .../harness/jungian-engine.mjs | 2 +- package-lock.json | 4 +- package.json | 26 +- scripts/AGENTS.md | 21 + scripts/CLAUDE.md | 1 + scripts/bounded-process.mjs | 148 ++++++ scripts/simfile-local-example.mjs | 48 ++ scripts/simfile-local-example.test.mjs | 17 + scripts/spawnfile-capability-probe.mjs | 126 +++++ scripts/spawnfile-composed-smoke.mjs | 179 +++++++ scripts/spawnfile-composed-smoke.test.mjs | 62 +++ scripts/spawnfile-development-context.mjs | 87 ++++ scripts/spawnfile-development-setup.mjs | 175 +++++++ scripts/spawnfile-development.mjs | 72 +++ scripts/spawnfile-development.test.mjs | 234 +++++++++ scripts/spawnfile-install-integrity.mjs | 122 +++++ scripts/spawnfile-install-integrity.test.mjs | 38 ++ scripts/spawnfile-local-endpoint.mjs | 59 +++ scripts/spawnfile-local-endpoint.test.mjs | 27 ++ scripts/spawnfile-source-stage.mjs | 55 +++ scripts/spawnfile-source-stage.test.mjs | 36 ++ src/cli/AGENTS.md | 22 +- src/cli/cliShared.ts | 47 ++ src/cli/compiledOrganizationIdentity.ts | 7 +- src/cli/composedBootstrapContract.ts | 37 ++ src/cli/composedBootstrapFinalize.ts | 102 ++++ src/cli/composedBootstrapLocal.ts | 165 +++++++ src/cli/composedBootstrapPaths.ts | 88 ++++ src/cli/composedBootstrapRecoverState.ts | 174 +++++++ src/cli/composedBootstrapRecovery.ts | 43 ++ src/cli/composedBootstrapState.ts | 55 +++ src/cli/composedCredentialRequest.ts | 53 +++ src/cli/composedExecutionBinding.ts | 108 +++++ src/cli/composedFailureCleanup.test.ts | 31 ++ src/cli/composedFailureCleanup.ts | 33 ++ src/cli/composedPreflightReport.test.ts | 71 +++ src/cli/composedPreflightReport.ts | 63 +++ src/cli/composedProjectDescriptor.ts | 104 ++++ src/cli/composedProjectPreflight.test.ts | 44 ++ src/cli/composedProjectPreflight.ts | 91 ++++ src/cli/composedRouting.test.ts | 23 +- src/cli/composedRunBootstrap.test.ts | 104 ++-- src/cli/composedRunBootstrap.ts | 440 +++-------------- src/cli/composedRunCommand.ts | 137 ++---- src/cli/composedRunCompletion.ts | 108 +++++ src/cli/composedSpawnfileAdmission.ts | 105 +++++ src/cli/composedSupportRoot.test.ts | 57 +++ src/cli/composedSupportRoot.ts | 38 ++ src/cli/composedWorldBindings.test.ts | 37 ++ src/cli/composedWorldBindings.ts | 58 +++ src/cli/index.ts | 260 +--------- src/cli/recover.test.ts | 422 ++--------------- src/cli/recover.ts | 80 +++- src/cli/recoverAuthority.test-helper.ts | 41 ++ src/cli/runArguments.test.ts | 3 +- src/cli/runArguments.ts | 20 + src/cli/runCommand.ts | 68 +++ src/cli/runRoute.test.ts | 6 +- src/cli/runRoute.ts | 7 +- src/cli/validateCommand.ts | 66 +++ src/compose/AGENTS.md | 16 +- src/compose/bootstrapAuthority.ts | 121 +++++ src/compose/bootstrapJournal.test.ts | 129 +++++ src/compose/bootstrapOperationContract.ts | 33 ++ src/compose/bootstrapOperationJournal.ts | 83 ++++ src/compose/commandReceipt.test.ts | 2 +- src/compose/commandReceipt.ts | 8 +- src/compose/composed-autonomy.test.ts | 8 +- src/compose/execution.ts | 32 +- src/compose/index.ts | 5 + src/compose/journal.ts | 318 +------------ src/compose/journalGenesis.ts | 77 +++ src/compose/journalSchema.ts | 65 +++ src/compose/journalStore.ts | 40 ++ src/compose/journalTransitions.ts | 94 ++++ src/compose/journalValidation.ts | 170 +++++++ src/compose/json.ts | 3 +- src/compose/lifecycle.test-helper.ts | 2 +- src/compose/operationJournal.ts | 40 ++ src/compose/phase-journal.test.ts | 23 +- src/compose/projectBinding.ts | 10 +- src/compose/recovery.ts | 63 +-- src/compose/recoveryCommand.ts | 52 ++ src/compose/request-receipt.test.ts | 9 +- src/compose/smokeCommandReceipt.test.ts | 104 ++++ src/compose/smokeCommandReceipt.ts | 178 +++++++ src/compose/startup-organization.ts | 232 ++------- src/compose/startupOrganizationReceipt.ts | 123 +++++ src/compose/superviseServices.test.ts | 10 +- src/compose/supervision.test.ts | 73 +++ src/compose/supervision.ts | 47 +- src/compose/supervisionTimeout.ts | 72 +++ src/compose/terminalOutcome.ts | 24 + src/moltnet/machine/CLAUDE.md | 1 + src/observe/AGENTS.md | 4 +- src/observe/observe.ts | 2 +- src/run/AGENTS.md | 7 +- src/sims/AGENTS.md | 11 + src/sims/CLAUDE.md | 1 + src/spawnfile/AGENTS.md | 33 +- src/spawnfile/bootstrapCli.test.ts | 158 +------ src/spawnfile/bootstrapCli.ts | 139 +----- src/spawnfile/cli.test.ts | 10 +- src/spawnfile/cli.ts | 320 +------------ src/spawnfile/composedPreparationCli.ts | 48 ++ src/spawnfile/composedTargetProvider.test.ts | 113 +++++ src/spawnfile/composedTargetProvider.ts | 199 ++++++++ src/spawnfile/containerBundleCli.test.ts | 29 ++ src/spawnfile/containerBundleCli.ts | 149 ++++++ src/spawnfile/evidenceHelperCli.ts | 34 ++ src/spawnfile/executableIdentity.ts | 37 ++ .../journaledCredentialProvisioning.test.ts | 106 +++++ .../journaledCredentialProvisioning.ts | 86 ++++ src/spawnfile/lifecycleLookup.test.ts | 59 +++ src/spawnfile/lifecycleLookup.ts | 97 ++++ .../organizationAuthentication.test.ts | 58 +++ src/spawnfile/organizationAuthentication.ts | 54 +++ src/spawnfile/organizationEvidenceCli.ts | 60 +++ src/spawnfile/organizationUpCli.ts | 50 ++ .../preparationReceipt.test-helper.ts | 4 +- src/spawnfile/process.test.ts | 33 +- src/spawnfile/process.ts | 166 +++---- src/spawnfile/processTree.ts | 45 ++ src/spawnfile/productionCleanupPorts.ts | 93 ++++ src/spawnfile/productionFinalizationPorts.ts | 111 +++++ .../productionOrganizationPorts.test.ts | 147 ++++++ src/spawnfile/productionOrganizationPorts.ts | 31 +- src/spawnfile/productionPorts.test.ts | 25 + src/spawnfile/productionPorts.ts | 390 ++------------- src/spawnfile/productionTarget.ts | 44 +- src/spawnfile/productionTerminal.test.ts | 160 +++++++ src/spawnfile/productionTerminal.ts | 49 +- src/spawnfile/productionTopologyPorts.ts | 74 +++ src/spawnfile/productionWorldPorts.ts | 122 +++++ src/spawnfile/publicCapabilityContract.ts | 72 +++ src/spawnfile/publicCapabilityProbe.test.ts | 124 +++++ src/spawnfile/publicCapabilityProbe.ts | 167 +++++++ src/spawnfile/publicSurface.test.ts | 6 +- src/spawnfile/spawnfileCliShared.ts | 38 ++ src/spawnfile/targetBootstrap.ts | 174 +++++++ src/spawnfile/targetCommandCli.ts | 38 ++ src/spawnfile/targetConfigPreview.ts | 54 +++ src/spawnfile/targetConfigResolution.ts | 87 ++++ src/spawnfile/targetOperationLookup.test.ts | 53 +++ src/spawnfile/targetOperationLookup.ts | 64 +++ src/spawnfile/targetPublicArtifact.ts | 60 +++ src/spawnfile/targetReceipts.test.ts | 24 + src/spawnfile/targetReceipts.ts | 267 +---------- src/spawnfile/targetResourceReceipts.ts | 94 ++++ src/spawnfile/targetSelection.ts | 43 ++ src/spawnfile/targetWorldReceipts.ts | 69 +++ src/test-support/AGENTS.md | 9 + src/test-support/CLAUDE.md | 1 + src/view/AGENTS.md | 5 +- src/world-artifact/AGENTS.md | 6 +- .../composedDevelopmentExample.test.ts | 358 ++++++++++++++ src/world-artifact/entrypoint.ts | 6 + src/world-artifact/index.ts | 10 + .../jungianDialogueExample.test.ts | 131 +++++ src/world-artifact/terminalSignal.test.ts | 29 ++ src/world-artifact/terminalSignal.ts | 67 +++ tools/AGENTS.md | 4 +- tools/package-closure-contract.mjs | 109 +++++ tools/package-closure-install.mjs | 106 +++++ tools/verify-package-closure.mjs | 210 ++------- web/src/store/AGENTS.md | 9 +- web/src/store/deepLink.test.ts | 29 +- web/src/store/deepLink.ts | 18 +- web/src/store/timeline.test.ts | 8 + web/src/store/timeline.ts | 5 + web/src/styles-replay.css | 15 +- web/src/viewer/AGENTS.md | 4 +- web/src/viewer/ChatPane.test.ts | 15 +- web/src/viewer/ChatPane.tsx | 2 + web/src/viewer/ReplayPrimaryPane.tsx | 36 ++ web/src/viewer/RunReplayShell.tsx | 88 ++-- web/src/viewer/replayPanel.test.ts | 66 +++ web/src/viewer/replayPanel.ts | 16 + .../src/components/LandingWorldSection.astro | 71 +++ website/src/content/docs/concepts.md | 18 +- website/src/content/docs/guides/memetics.md | 27 +- .../docs/guides/spawnfile-integration.md | 107 ++++- website/src/content/docs/guides/viewer.md | 26 +- website/src/content/docs/introduction.md | 19 +- website/src/content/docs/quickstart.md | 96 +++- website/src/content/docs/reference/cli.md | 75 ++- website/src/content/docs/reference/simfile.md | 35 +- website/src/pages/index.astro | 92 +--- 253 files changed, 12954 insertions(+), 3682 deletions(-) create mode 100644 LICENSE create mode 100644 PLAN.md create mode 100644 PLAN_REVIEW.md create mode 100644 examples/AGENTS.md create mode 120000 examples/CLAUDE.md create mode 100644 examples/composed-development/AGENTS.md create mode 120000 examples/composed-development/CLAUDE.md create mode 100644 examples/composed-development/README.md create mode 100644 examples/composed-development/Simfile create mode 100644 examples/composed-development/binding-world.mjs create mode 100644 examples/composed-development/binding.mjs create mode 100644 examples/composed-development/harness/AGENTS.md create mode 120000 examples/composed-development/harness/CLAUDE.md create mode 100755 examples/composed-development/harness/scripted-engine.mjs create mode 100644 examples/composed-development/org/AGENTS.md create mode 120000 examples/composed-development/org/CLAUDE.md create mode 100644 examples/composed-development/org/Spawnfile create mode 100644 examples/composed-development/org/TEAM.md create mode 100644 examples/composed-development/org/agents/smoke/AGENTS.md create mode 120000 examples/composed-development/org/agents/smoke/CLAUDE.md create mode 100644 examples/composed-development/org/agents/smoke/Spawnfile create mode 100644 examples/composed-development/world/AGENTS.md create mode 120000 examples/composed-development/world/CLAUDE.md create mode 100644 examples/composed-development/world/composer.mjs create mode 100644 examples/composed-development/world/evidence.mjs create mode 100644 examples/composed-development/world/provider.mjs create mode 100644 examples/composed-development/world/surface.mjs create mode 100644 examples/jungian-dialogue/AGENTS.md create mode 120000 examples/jungian-dialogue/CLAUDE.md create mode 100644 examples/jungian-dialogue/README.md create mode 100644 examples/jungian-dialogue/Simfile create mode 100644 examples/jungian-dialogue/binding-world.mjs create mode 100644 examples/jungian-dialogue/binding.mjs create mode 100644 examples/jungian-dialogue/harness/AGENTS.md create mode 120000 examples/jungian-dialogue/harness/CLAUDE.md create mode 100755 examples/jungian-dialogue/harness/jungian-engine.mjs create mode 100644 examples/jungian-dialogue/org/AGENTS.md create mode 120000 examples/jungian-dialogue/org/CLAUDE.md create mode 100644 examples/jungian-dialogue/org/Spawnfile create mode 100644 examples/jungian-dialogue/org/TEAM.md create mode 100644 examples/jungian-dialogue/org/agents/analyst/AGENTS.md create mode 120000 examples/jungian-dialogue/org/agents/analyst/CLAUDE.md create mode 100644 examples/jungian-dialogue/org/agents/analyst/Spawnfile create mode 100644 examples/jungian-dialogue/org/agents/daimon/AGENTS.md create mode 120000 examples/jungian-dialogue/org/agents/daimon/CLAUDE.md create mode 100644 examples/jungian-dialogue/org/agents/daimon/Spawnfile create mode 100644 examples/jungian-dialogue/world/AGENTS.md create mode 120000 examples/jungian-dialogue/world/CLAUDE.md create mode 100644 examples/jungian-dialogue/world/composer.mjs create mode 100644 examples/jungian-dialogue/world/evidence.mjs create mode 100644 examples/jungian-dialogue/world/provider.mjs create mode 100644 examples/jungian-dialogue/world/surface.mjs create mode 100644 scripts/AGENTS.md create mode 120000 scripts/CLAUDE.md create mode 100644 scripts/bounded-process.mjs create mode 100644 scripts/simfile-local-example.mjs create mode 100644 scripts/simfile-local-example.test.mjs create mode 100644 scripts/spawnfile-capability-probe.mjs create mode 100755 scripts/spawnfile-composed-smoke.mjs create mode 100644 scripts/spawnfile-composed-smoke.test.mjs create mode 100644 scripts/spawnfile-development-context.mjs create mode 100644 scripts/spawnfile-development-setup.mjs create mode 100644 scripts/spawnfile-development.mjs create mode 100644 scripts/spawnfile-development.test.mjs create mode 100644 scripts/spawnfile-install-integrity.mjs create mode 100644 scripts/spawnfile-install-integrity.test.mjs create mode 100644 scripts/spawnfile-local-endpoint.mjs create mode 100644 scripts/spawnfile-local-endpoint.test.mjs create mode 100644 scripts/spawnfile-source-stage.mjs create mode 100644 scripts/spawnfile-source-stage.test.mjs create mode 100644 src/cli/cliShared.ts create mode 100644 src/cli/composedBootstrapContract.ts create mode 100644 src/cli/composedBootstrapFinalize.ts create mode 100644 src/cli/composedBootstrapLocal.ts create mode 100644 src/cli/composedBootstrapPaths.ts create mode 100644 src/cli/composedBootstrapRecoverState.ts create mode 100644 src/cli/composedBootstrapRecovery.ts create mode 100644 src/cli/composedBootstrapState.ts create mode 100644 src/cli/composedCredentialRequest.ts create mode 100644 src/cli/composedExecutionBinding.ts create mode 100644 src/cli/composedFailureCleanup.test.ts create mode 100644 src/cli/composedFailureCleanup.ts create mode 100644 src/cli/composedPreflightReport.test.ts create mode 100644 src/cli/composedPreflightReport.ts create mode 100644 src/cli/composedProjectDescriptor.ts create mode 100644 src/cli/composedProjectPreflight.test.ts create mode 100644 src/cli/composedProjectPreflight.ts create mode 100644 src/cli/composedRunCompletion.ts create mode 100644 src/cli/composedSpawnfileAdmission.ts create mode 100644 src/cli/composedSupportRoot.test.ts create mode 100644 src/cli/composedSupportRoot.ts create mode 100644 src/cli/composedWorldBindings.test.ts create mode 100644 src/cli/composedWorldBindings.ts create mode 100644 src/cli/runCommand.ts create mode 100644 src/cli/validateCommand.ts create mode 100644 src/compose/bootstrapAuthority.ts create mode 100644 src/compose/bootstrapJournal.test.ts create mode 100644 src/compose/bootstrapOperationContract.ts create mode 100644 src/compose/bootstrapOperationJournal.ts create mode 100644 src/compose/journalGenesis.ts create mode 100644 src/compose/journalSchema.ts create mode 100644 src/compose/journalStore.ts create mode 100644 src/compose/journalTransitions.ts create mode 100644 src/compose/journalValidation.ts create mode 100644 src/compose/operationJournal.ts create mode 100644 src/compose/recoveryCommand.ts create mode 100644 src/compose/smokeCommandReceipt.test.ts create mode 100644 src/compose/smokeCommandReceipt.ts create mode 100644 src/compose/startupOrganizationReceipt.ts create mode 100644 src/compose/supervisionTimeout.ts create mode 100644 src/compose/terminalOutcome.ts create mode 120000 src/moltnet/machine/CLAUDE.md create mode 100644 src/sims/AGENTS.md create mode 120000 src/sims/CLAUDE.md create mode 100644 src/spawnfile/composedPreparationCli.ts create mode 100644 src/spawnfile/composedTargetProvider.test.ts create mode 100644 src/spawnfile/composedTargetProvider.ts create mode 100644 src/spawnfile/containerBundleCli.test.ts create mode 100644 src/spawnfile/containerBundleCli.ts create mode 100644 src/spawnfile/evidenceHelperCli.ts create mode 100644 src/spawnfile/executableIdentity.ts create mode 100644 src/spawnfile/journaledCredentialProvisioning.test.ts create mode 100644 src/spawnfile/journaledCredentialProvisioning.ts create mode 100644 src/spawnfile/lifecycleLookup.test.ts create mode 100644 src/spawnfile/lifecycleLookup.ts create mode 100644 src/spawnfile/organizationAuthentication.test.ts create mode 100644 src/spawnfile/organizationAuthentication.ts create mode 100644 src/spawnfile/organizationEvidenceCli.ts create mode 100644 src/spawnfile/organizationUpCli.ts create mode 100644 src/spawnfile/processTree.ts create mode 100644 src/spawnfile/productionCleanupPorts.ts create mode 100644 src/spawnfile/productionFinalizationPorts.ts create mode 100644 src/spawnfile/productionOrganizationPorts.test.ts create mode 100644 src/spawnfile/productionPorts.test.ts create mode 100644 src/spawnfile/productionTerminal.test.ts create mode 100644 src/spawnfile/productionTopologyPorts.ts create mode 100644 src/spawnfile/productionWorldPorts.ts create mode 100644 src/spawnfile/publicCapabilityContract.ts create mode 100644 src/spawnfile/publicCapabilityProbe.test.ts create mode 100644 src/spawnfile/publicCapabilityProbe.ts create mode 100644 src/spawnfile/spawnfileCliShared.ts create mode 100644 src/spawnfile/targetBootstrap.ts create mode 100644 src/spawnfile/targetCommandCli.ts create mode 100644 src/spawnfile/targetConfigPreview.ts create mode 100644 src/spawnfile/targetConfigResolution.ts create mode 100644 src/spawnfile/targetOperationLookup.test.ts create mode 100644 src/spawnfile/targetOperationLookup.ts create mode 100644 src/spawnfile/targetPublicArtifact.ts create mode 100644 src/spawnfile/targetResourceReceipts.ts create mode 100644 src/spawnfile/targetSelection.ts create mode 100644 src/spawnfile/targetWorldReceipts.ts create mode 100644 src/test-support/AGENTS.md create mode 120000 src/test-support/CLAUDE.md create mode 100644 src/world-artifact/composedDevelopmentExample.test.ts create mode 100644 src/world-artifact/jungianDialogueExample.test.ts create mode 100644 src/world-artifact/terminalSignal.test.ts create mode 100644 src/world-artifact/terminalSignal.ts create mode 100644 tools/package-closure-contract.mjs create mode 100644 tools/package-closure-install.mjs create mode 100644 web/src/viewer/ReplayPrimaryPane.tsx create mode 100644 web/src/viewer/replayPanel.test.ts create mode 100644 web/src/viewer/replayPanel.ts create mode 100644 website/src/components/LandingWorldSection.astro diff --git a/.gitignore b/.gitignore index 21a2b6a..1220b99 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist/ coverage/ !src/coverage/ .artifacts/ +.simfile-dev/ +.simfile-composed/ runs/* !runs/real-grok-composed/ !runs/office-world-v0/ diff --git a/AGENTS.md b/AGENTS.md index 886e9d0..cc699ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # Simfile Working Guide -This folder, inside the Spawnfile repository, is the reference implementation -of the Simfile v0.1 world mechanics package. +This standalone repository is the reference implementation of the Simfile +v0.1 world mechanics package. Spawnfile may be installed as a separate tool or +checked out anywhere; never infer a sibling repository or import its source. ## Repository Structure @@ -28,6 +29,8 @@ of the Simfile v0.1 world mechanics package. - Keep CLI handlers thin; schema, planning, ledger, and runtime logic belong in modules. - Do not import Spawnfile internals. Consume explicit machine-readable artifacts. - Do not add Docker compilation, runtime auth, or deployment ownership here. +- Source-development tool setup belongs under ignored `.simfile-dev/` state and + must require an explicit package coordinate or absolute checkout path. - `simfile run` may compose a linked Spawnfile lifecycle only through documented CLI operations and versioned receipts. Lifecycle composition never selects, wakes, invokes, polls, or waits for agent cognition. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45edc4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Noopolis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..9bf029a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,446 @@ +# Simfile Standalone Composed Example Plan + +This plan changes Simfile only. Spawnfile is an immutable external CLI owned by +the user's separate Spawnfile thread. Do not modify Spawnfile or depend on any +current uncommitted Spawnfile changes. + +## Status legend + +- **done** — implemented and verified within the authority of this repository. +- **implemented-awaiting-compatible-acceptance** — implemented and covered by + local/contract tests, but the real compatible external flow is not yet + accepted. +- **open** — actionable Simfile work remains. +- **external-blocked** — completion requires a released, consumer-neutral + Spawnfile contract or an explicitly selected local external environment. + +## Current landing gate + +The composed wrapper intentionally fails its read-only preflight against the +immutable installed Spawnfile 0.1.14. That release does not expose the generic, +machine-verifiable resolver, evidence-export, and typed terminal-pending +capabilities Simfile requires. Local tests being green does **not** equal a +successful external composed acceptance. + +A reviewed but **unreleased** generic Spawnfile draft is also not an +acceptance candidate: its local evidence-helper path requires an OCI +`RepoDigest` that a classic local Docker build may not have, and it has no +durable mutation-recovery authority. Its image-mode `up` receipt is likewise +not sufficient to reconstruct the composed lifecycle on recovery. Simfile must +not parse, pin, or execute that draft through a sibling/source checkout. + +The independent boundary review additionally found that the draft retains +caller-managed helper-authority paths, has a race in public-artifact reads, +does not enumerate a complete lifecycle contract set, and cannot support a +fresh-process Simfile provider reconstruction. All are external P1 release +blockers. A compatible artifact must be version-bumped; it must never reuse +the already-installed `0.1.14` identity. + +The former manual target environment/helper ABI has been removed from the +composed product path. Simfile now has a consumer-neutral, journal-aware +internal target-provider seam whose default fails closed until an exact +released public contract can be adapted. Consequently no target preparation, +credential provisioning, staging, or support-root creation can occur after +preflight today. Do not make the probe return ready, land the composed path, +or claim it runnable until generic external resolution is wired behind that +seam with journal authority established before its first mutation. + +Landing is also premature while the feature remains a dirty/untracked delivery +set. Every feature file must be accounted for, the branch must safely include +upstream, and the full validation and acceptance gates below must pass. + +## 1. Protect the repository boundary + +**Status: done (boundary), external-blocked (generic coordination).** + +- [x] Make changes only inside `simfile/` from this workstream. +- [x] Treat Spawnfile as an external immutable CLI. +- [x] Do not add Simfile examples, profiles, receipt names, or dependencies to + Spawnfile. +- [x] Do not depend on experimental uncommitted Spawnfile changes. +- [ ] Give the Spawnfile thread only a generic capability checklist; final + compatible contract identity remains external-blocked. + +## 2. Organize Simfile examples and fixtures + +**Status: open.** The canonical example exists and is contract-tested; the +fixture inventory still needs an item-by-item purpose audit. Do not broadly +relocate e2e or test-contract fixtures. + +- [x] Create top-level `examples/` for user-runnable projects. +- [ ] Keep `fixtures/` only for malformed inputs, edge cases, golden outputs, + and isolated contracts; audit each existing fixture by actual purpose before + moving or deleting it. +- [x] Canonicalize the actual checked-in tree: + + ```text + examples/composed-development/ + ├── AGENTS.md + ├── CLAUDE.md -> AGENTS.md + ├── README.md + ├── Simfile + ├── binding.mjs + ├── harness/ + │ ├── AGENTS.md + │ ├── CLAUDE.md -> AGENTS.md + │ └── scripted-engine.mjs + ├── org/ + │ ├── AGENTS.md + │ ├── CLAUDE.md -> AGENTS.md + │ ├── Spawnfile + │ ├── TEAM.md + │ └── agents/smoke/ + │ ├── AGENTS.md + │ ├── CLAUDE.md -> AGENTS.md + │ └── Spawnfile + └── world/ + ├── AGENTS.md + ├── CLAUDE.md -> AGENTS.md + ├── composer.mjs + ├── evidence.mjs + ├── provider.mjs + └── surface.mjs + ``` + +- [x] Make tests consume the exact example rather than a copy. +- [ ] Give each example one distinct workflow; remove or merge only when a + duplicate purpose is proven by the fixture audit. +- [x] Use project-relative paths only; reject absolute, sibling, user, private + host, GPU, and private-auth assumptions. + +## 3. Build a complete composed Simfile project + +**Status: implemented-awaiting-compatible-acceptance.** + +- [x] Make the root Simfile declare its clock and seed, project-local + organization Spawnfile, binding and composer, and finite terminal tick. +- [x] Export `composedProjectBinding` from `binding.mjs`. +- [x] Import only published Simfile exports; never import `simfile/src` or + Spawnfile internals. +- [x] Build a deterministic, independently verifiable world artifact. +- [x] Declare members, principals, grants, readiness, replay, and evidence + mappings. + +## 4. Produce complete world evidence + +**Status: implemented-awaiting-compatible-acceptance.** The exact checked-in +example controller contract test is complete without Docker. + +- [x] Write initial and terminal checkpoints, accepted-action ledger, result + ledger, principal projection, probe, replay expectation, and terminal signal. +- [x] Publish the terminal signal atomically at the terminal tick. +- [x] Use one public Simfile terminal path and contract constant. +- [x] Make replay restore initial state, apply recorded inputs, and reproduce + terminal state. +- [x] Never fabricate agent actions. +- [x] Execute the emitted controller in the exact-example contract test and + verify evidence, terminal checkpoint, terminal signal, and replay mapping. + +## 5. Add honest development-smoke semantics + +**Status: implemented-awaiting-compatible-acceptance; command execution is +external-blocked.** The canonical explicit syntax is: + +```bash +simfile run ./examples/composed-development/Simfile \ + --mode lifecycle-replay-smoke +``` + +- [x] Canonicalize `--mode lifecycle-replay-smoke`; do not introduce a second + `--smoke` spelling. +- [x] Leave existing strict live behavior unchanged; keep the cross-repo + Spawnfile request within a generic supported mode. +- [x] Keep smoke mode only in Simfile local execution and its separate, + versioned receipt. +- [ ] Make the real external smoke prove validation and compile, artifact, + lifecycle, readiness, terminal, evidence export, cleanup, sealing, and + deterministic replay. +- [x] Report live agent-action evidence as `not_evaluated`; never present smoke + as live-action success. +- [x] Require authenticated accepted actions for every required principal in + normal live mode. +- [x] Unit/contract evidence proves the smoke action stream is empty and the + smoke receipt says `not_evaluated`, while strict live remains strict. + +## 6. Make scripted development credential-free + +**Status: implemented-awaiting-compatible-acceptance.** Scripted auth +classification is locally tested; real external execution remains blocked. + +- [x] Inspect compiled engines. +- [x] For an all-scripted organization, use no auth profile, model credential + request, credential-store read, or Spawnfile auth flag. +- [x] When Codex is present, require an explicit profile and use the supported + Codex request. +- [x] For another non-scripted engine, require an explicit Spawnfile profile + but never falsely label it as Codex. + +## 7. Add standalone Spawnfile installation for Simfile development + +**Status: implemented-awaiting-compatible-acceptance.** Isolated package/source +setup and full installed dependency-closure attestation work locally; a +fresh-clone exercise remains open. + +- [x] Support exact package installation: + + ```bash + npm run dev:spawnfile:setup -- --package spawnfile@ + ``` + +- [x] Also support an explicit, physical, absolute source checkout that is + verified, copied to a private stage, packed with `npm pack`, and installed in + isolation without mutating the source checkout. +- [x] Never infer `../spawnfile`, use a `file:` sibling dependency, silently use + a global installation, execute source directly, or import Spawnfile. +- [x] Record binary, version, origin, tarball digest, executable digest, and + probe identity in ignored private state, and reverify them. +- [x] Attest the installed Spawnfile module/dependency closure, not only the + unchanged CLI entrypoint and saved tarball. + +## 8. Implement a Simfile-owned compatibility preflight + +**Status: implemented-awaiting-compatible-acceptance and external-blocked.** +Spawnfile 0.1.14 correctly reports not ready and Simfile stops before mutation. + +- [x] Use no Spawnfile `simfile.*` profile. +- [x] Probe only generic documented Spawnfile surfaces and emit a Simfile-owned + report. +- [x] Prefer `spawnfile capabilities --json` when available; strictly parse + `spawnfile.capabilities.v1`, the complete + `spawnfile.composed-lifecycle-contract-set.v1`, and all 43 declared command + rows before considering any future adapter. Fall back to legacy help only to + explain why Spawnfile 0.1.14 is unverified. +- [ ] Check executable and version, validate and compile, generic target + resolver and config receipt, evidence export, terminal snapshot and typed + pending, optional model auth, and prepared-plan contract through exact + released contract identities. +- [x] Run the current fail-closed preflight before state, image pulls, remote + contact, credentials, or containers. +- [x] Report exact blockers and perform zero mutation on failure. +- [x] Exercise the built linked CLI against the isolated exact + `spawnfile@0.1.14` artifact: its missing generic capability command produces + explicit blockers and creates neither an output directory nor support state. +- [ ] Obtain consumer-neutral generic capability discovery covering evidence + export and typed terminal pending from an exact compatible Spawnfile release. +- [ ] Require a pinned released `spawnfile capabilities --json` identity before + adapting any future capability report. Parsed discovery deliberately remains + `simfile_target_provider_not_admitted` until an independently installable, + pinned artifact can bind its opaque target provider state to the durable + journal. + +## 9. Keep Docker and target ownership in Spawnfile + +**Status: implemented-awaiting-compatible-acceptance (seam), external-blocked +(provider).** + +- [x] Ensure Simfile never directly inspects Docker or implements deployment. +- [ ] Let only the external Spawnfile CLI resolve configuration, provision + target/evidence helpers, deploy, and revoke its owned resources. +- [ ] Auto-select only the current local context; require an explicit choice + for a remote target. +- [ ] Require the generic response to supply context, classification, + architecture, base reference, config digest, strict config, and evidence + authority under exact released contract identities. +- [x] Remove the manual target environment/helper ABI from the implementation, + not merely from documentation. The composed execution schema and production + target driver contain no helper executable, target-config bytes, or + legacy target-helper environment path. +- [ ] Verify working image-mode `up --json` only if the actual admitted Simfile + invocation requires it. +- [ ] Reject a future image-mode receipt unless it has enough versioned, + journal-reconstructible lifecycle and cleanup authority for recovery; the + reviewed draft `spawnfile.image-up-receipt.v1` does not yet meet that bar. + +## 10. Make bootstrap failures recoverable + +**Status: implemented-awaiting-compatible-acceptance.** The current +incompatible preflight is safe and the removed adapter can perform no mutation +after it. The future released provider integration remains gated on creating +the durable journal before its first target/auth mutation. + +- [x] Perform read-only validation and compile before mutation where possible. +- [x] Create an exclusive private support root. +- [x] On a pre-journal failure, revoke every created credential and delete only + the exact run-owned support root without poisoning retry. +- [x] Remove the pre-journal target/auth mutation path. The default target + provider fails before any support root, target preparation, staging, or + credential operation; its recovery test preserves a durable journal. +- [ ] When the released provider is integrated, journal before its first + target/auth mutation, preserve the journal on failure or interruption, and + print the exact recovery command. +- [ ] Ensure Spawnfile alone provisions and revokes target/auth resources; + Simfile records only public handles/receipts needed for recovery. +- [x] Prove that current record/journal admission cannot leak target + preparation, credentials, staging, or the exclusive support root: no such + operation is reachable before the provider seam is admitted. +- [x] Keep `simfile recover` fail-closed before constructing production ports + when no provider is admitted; it verifies and preserves the durable journal + byte-for-byte rather than replaying an old journal through legacy lifecycle + commands. + +## 11. Bound terminal polling + +**Status: done for the composed terminal path.** + +- [x] Retry only the exact typed not-present condition. +- [x] Treat malformed, correlation, target, container, and schema errors as + permanent. +- [x] Use an explicit timeout, abort the underlying operation, await + quiescence, and retain no timers or background loop. +- [x] Bound the uncooperative-port quiescence failure itself. + +## 12. Add convenient source-development commands + +**Status: open.** Aliases and unique planned IDs/outputs are implemented in the +working tree, but final validation and a real compatible composed invocation +remain open. + +- [x] Add these commands: + + ```text + example:local + dev:spawnfile:setup + dev:spawnfile:check + example:composed + ``` + +- [x] Make `example:local` invoke the built CLI and canonical example with a + unique default run ID and output path. +- [x] Make `example:composed` plan the built CLI, canonical example, explicit + smoke mode, and unique default run ID/output while retaining fail-closed + preflight. +- [ ] After generic external integration, make `example:composed` actually + invoke the built CLI rather than ending at the compatibility gate. +- [ ] Print the verified Spawnfile identity, selected local target, run + directory, mode, viewer command, and recovery command when those authorities + actually exist; never fabricate them on preflight failure. +- [x] Keep the direct CLI available. + +## 13. Document the exact clean-clone path + +**Status: implemented-awaiting-compatible-acceptance.** README, example, and +site wording now describe the aliases, unique outputs, preflight-only composed +state, recovery ownership, and strict/live smoke distinction. Fresh-clone and +compatible external acceptance remain required. + +- [x] Put clone, `npm ci`, build, `example:local`, setup, and + `example:composed` commands in the README. +- [x] Document Node.js >=22.19, local Docker for the future external acceptance, + first-download network access, and that the scripted smoke needs no model + credentials. +- [x] Make the example README own its complete file map, modes, output, + replay/view, cleanup/recovery, and transition to a real engine. +- [x] Keep the website quickstart, CLI reference, and integration guide in + agreement with the current preflight-only behavior; remove obsolete fixture + and private-infrastructure claims. +- [ ] Run the documented flow from a genuinely fresh clean clone. + +## 14. Test without duplicating the example + +**Status: implemented-awaiting-compatible-acceptance, with external acceptance +gates open.** + +- [x] Add unit tests for flags, receipt semantics, strict verdict, auth + classification, discovery, compatibility, rollback helpers, cancellation, + and the pending receipt. +- [x] Contract-test the exact checked-in example: schema, paths, binding, + artifact, emitted controller, terminal, evidence, and replay. +- [x] Use fake Spawnfile processes to verify public CLI use, zero state on + incompatible preflight, no model credentials for scripted mode, typed + pending, cancellation, and executable identity. +- [x] Extend package closure to import and build the exact packed + `examples/composed-development/binding.mjs` from a temporary external + package root, not merely assert that its files are present. +- [ ] Keep real local-Docker acceptance opt-in and run it only after a + compatible Spawnfile is installed; never select a remote target + automatically. + +## 15. Coordinate with the separate Spawnfile thread + +**Status: external-blocked.** + +- [ ] Send only this generic checklist: machine-readable consumer-neutral + capabilities, safe resolver, evidence-export provisioning, typed terminal + pending, optional model auth, and unique versioned contract identities. +- [x] Never modify Spawnfile here. +- [ ] Wait for an exact compatible artifact, pin it, and run clean-clone + acceptance. +- [ ] Record the exact compatible Spawnfile version and contract IDs before + enabling the composed adapter. +- [ ] Do not integrate the reviewed unreleased draft: its evidence-helper + `RepoDigest` assumption and missing durable target/auth recovery are P1 + blockers, and no artifact newer than installed Spawnfile 0.1.14 is pinned. +- [ ] Await a version-bumped artifact only after the separate Spawnfile thread + removes caller-managed helper authority, immutably binds the evidence-helper + receipt to its accepted image-config digest, proves public-artifact reads are + race-safe, publishes a complete and truthful lifecycle contract-set identity + (including lookup, project-mode up/down/export, terminal, and credential + semantics), and passes fresh-process provider recovery plus classic + local-Docker acceptance. The current draft fails this gate and still reports + the already-released `0.1.14` package identity. + +## 16. Apply the review loop + +**Status: open.** Terra implementation and independent Sol P0-P4 reviews have +run, but this plan deliberately retains the P1/P2 landing gates above. + +- [x] Have subagents perform every implementation and test action. +- [x] Use bounded Terra implementation and independent Sol review using P0-P4. +- [ ] Before the next implementation phase, fix every P0, P1, and borderline + P2; the pre-journal ownership P1 remains open. +- [ ] In the final repository-wide review, resolve relevant remaining P2+. +- [x] Scan portability for usernames, personal paths, private hosts, GPU + labels, sibling imports, and stale ecosystem paths; the current scoped scan + found no private-machine spillover. + +## Validation and delivery gates + +**Status: open.** Current local validation is green, but unaccounted delivery +state and external acceptance remain open. + +- [x] The prior full repository run passed 1,670 tests with zero failures. +- [x] Focused composed tests (78 assertions), script tests (12 assertions), + typecheck, site build, and the strengthened package-closure verifier passed + on the current worktree. +- [x] Rerun focused/unit tests, typecheck, package-closure verification, and + the site build on the current working tree. +- [x] The serial full-repository rerun for the current provider-seam + refinement passed 1,670 tests with zero failures. +- [x] The canonical local source-checkout example completed with a unique run + directory, and `simfile observe --json` verified its sealed artifact + digests. The generated ignored run was then removed. +- [ ] Account for every dirty and untracked feature file; exclude unrelated + user work from the delivery. +- [ ] Put the work on a safe branch that includes/reconciles current upstream + without destructive reset or silent conflict loss. +- [x] Preserve the proof that smoke action evidence is empty/`not_evaluated` + and strict live action evidence remains strict. +- [ ] Complete opt-in real local-only Docker acceptance using the exact pinned + compatible Spawnfile release. +- [ ] Complete fresh clean-clone acceptance from the documented commands. + +## Definition of done + +**Status: open and external-blocked; do not mark complete from local tests.** + +- [ ] A clean clone runs the local example immediately. +- [ ] The composed example is complete, self-contained, and invoked through + the Simfile CLI only. +- [ ] Spawnfile is independently installed and consumed only through its + generic public CLI. +- [x] The scripted smoke contract uses no model credentials and reports action + evidence as empty/`not_evaluated`, while strict live remains strict. +- [x] An incompatible Spawnfile fails before mutation. +- [ ] A compatible Spawnfile completes lifecycle, evidence, cleanup, replay, + and viewer flows in opt-in real local-only Docker acceptance. +- [x] No private-machine assumptions or Simfile-specific spillover enter + Spawnfile. + +## Out of scope / rejected review expansion + +The following are separate tasks and must not be smuggled into this workstream: + +- ecosystem-wide or all-repository edits; +- any Spawnfile implementation change from the Simfile thread; +- a broad receipt-first redesign of the entire CLI; +- broad fixture relocation without an item-by-item purpose audit. diff --git a/PLAN_REVIEW.md b/PLAN_REVIEW.md new file mode 100644 index 0000000..75b59fa --- /dev/null +++ b/PLAN_REVIEW.md @@ -0,0 +1,155 @@ +# PLAN.md Review + +> **Addendum (2026-08-15, after PLAN.md status update):** the plan now carries +> its own per-item statuses, which supersede the table in §1 where they +> differ. The differences are in the plan's favor — it is stricter: §9 and +> §10 are *not* done as my table said, because the behind-gate bootstrap +> still runs the manual `SPAWNFILE_TARGET_CONFIG_PRODUCER` ABI and mutates +> target/credential state before the durable journal owns recovery — both +> correctly held as P1 landing gates. The plan also resolved this review's +> naming P2s (canonicalized `--mode lifecycle-replay-smoke`, added +> `example:local`/`example:composed`, canonicalized the real example tree) +> and its out-of-scope section rightly keeps the broad receipt-first CLI +> redesign (§5 below) at ecosystem level, not in this workstream. + +Verdict: the plan is sound and the cleaning direction is right, but it is not +a plan anymore — roughly 85–90% of it already exists in the uncommitted +working tree. It reads as an in-flight checklist that has drifted from the +implementation in several names. It is not overkill in scope; it is overkill +only in presentation, because it re-specifies finished work as if it were +future work. Spawnfile is *not* uniformly this clean: its target/receipt +layer is, its project layer and capabilities handshake are not. + +## 1. Status per plan section (verified in the working tree) + +| Plan § | Status | Note | +|---|---|---| +| §1 Repository boundary | ✅ done | No Spawnfile changes; sibling `--source ../spawnfile` rejected by test. | +| §2 Examples vs fixtures | 🟡 partial | `examples/composed-development/` fully built; `fixtures/sims/` still holds four runnable projects; tree naming diverges from plan. | +| §3 Composed project | ✅ done | Simfile, binding, composer/provider, org tree all present. | +| §4 World evidence | ✅ done | Checkpoints, ledgers, terminal signal, replay implemented. | +| §5 Smoke semantics | 🟡 done, name differs | Exists as `--mode lifecycle-replay-smoke` with its own versioned receipt (`simfile.composed-lifecycle-replay-smoke-receipt.v1`); no `--smoke` shorthand. | +| §6 Credential-free scripted | ✅ done | No auth for scripted engines; Codex requires an explicit profile. | +| §7 Standalone install | ✅ done | `dev:spawnfile:setup` enforces exactly one of `--package spawnfile@x.y.z` / absolute `--source`, packs via `npm pack`, installs isolated, records digest in gitignored `.simfile-dev/`. | +| §8 Compatibility preflight | ✅ done | Simfile-owned probe (`simfile.spawnfile-public-capability-probe.v1`) + preflight module, run before mutation. | +| §9 Docker/target ownership | ✅ done | Delegated to Spawnfile's receipt commands; no Docker inspection in Simfile. | +| §10 Recoverable bootstrap | ✅ done | Journal session, recovery command, exclusive support root. | +| §11 Bounded polling | ✅ done | Retries only the typed not-present receipt; abort-aware, timers cleaned up. | +| §12 Dev commands | 🟡 partial | `dev:spawnfile:{setup,check,run,status}` exist; `example:local` and `example:composed` missing (`dev:spawnfile:run` is the de-facto latter). | +| §13 Docs | 🟡 partial | README documents setup/check/run and smoke mode; website/CLI-reference agreement unverified. | +| §14 Tests | ✅ largely done | Example contract test, fake-Spawnfile public-surface test, package-closure verification all present. | +| §15 Spawnfile-thread coordination | ⬜ open | External dependency — see §6 below for what to ask. | +| §16 Review loop | ⬜ open | Process item; applies to the remaining work. | +| Definition of done | ⬜ blocked | Fails only because nothing is committed. | + +Portability is already clean throughout: zero `file:../` deps, zero absolute +user paths in tracked files, no private-host references. + +## 2. Real remaining gaps (the actual plan) + +- **P1 — nothing is committed.** The entire feature set is uncommitted; + `git stash` or a clean clone reverts to a HEAD where none of it exists. The + Definition of Done ("clean clone runs immediately") is currently false for + the only reason that the work isn't landed. +- **P2 — plan/implementation naming is unreconciled** (pick one side; cheapest + is updating the plan to match reality, plus adding the two missing scripts): + - `--smoke` (plan) vs `--mode lifecycle-replay-smoke` (impl) + - `example:local`, `example:composed` (plan) vs only `dev:spawnfile:run` (impl) + - `organization/agents/tester/`, `world/composer.ts` (plan) vs + `org/agents/smoke/`, `world/composer.mjs` (impl) +- **P2 — fixtures cleanup (§2) is only half done.** `fixtures/sims/` still + holds four runnable example projects, and `fixtures/e2e/autonomous-office-sim` + is example-shaped; the README redirect exists but the projects remain. +- **P3 — tests-consume-the-exact-example** (§2) — an example contract test + exists; confirm it reads the checked-in tree rather than a copy. + +## 3. Is Spawnfile this clean already? No — two tiers + +**Clean tier (target/receipt boundary):** every `target …` subcommand emits +one canonical versioned JSON receipt; ~100 `spawnfile.*.v1` contract IDs; +`target resolve_config` is fully generic (context, architecture, base image, +config digest) and config-free; terminal snapshot has a typed `not-present` +receipt; `lookup_operation` has typed `pending`; auth provisioning treats +model-engine auth as optional (the scripted no-credential path is real) with +an explicit Codex profile kind. This is exactly the surface the plan consumes, +and it holds up. + +**Not-clean tier (everything else):** + +- `compile`, `build`, `run`, `publish`, `validate`, `view`, `dev *` have no + machine-readable output at all; `compile` forces stdout scraping to find + the report path. `up --json` throws in image mode. +- The capabilities handshake is hard-wired to `simfile.development.v1` / + `simfile.composed-run.v1` — the *only* accepted profiles, and the receipt + type literally cannot report an incompatibility. There is no generic + profile-less capabilities query. +- No schema/receipt registry command and no shipped JSON Schemas; consumers + hand-transcribe shapes from TARGETS.md. +- The `spawnfile.simfile-run-operator-*.v1` contracts are spec'd but wired to + no CLI command and not exported — unreachable by a CLI-only consumer. +- No mechanical import boundary: `package.json` has no `exports` field, so + deep imports of `spawnfile/dist/**` are unrestricted; the CLI-only rule is + convention, not enforcement. +- "No credentials" is expressed by *absence* of the auth field, so a receipt + cannot distinguish "scripted, deliberately unauthenticated" from "auth + forgotten". + +## 4. Tension inside the plan + +§8 says "use no Spawnfile `simfile.*` profile", but Spawnfile's +`compatibility` command accepts *only* `simfile.*` profiles. The +implementation resolves this correctly — Simfile's own probe checks generic +documented surfaces directly and skips `compatibility` — but the plan should +say so explicitly, and §15's checklist to the Spawnfile thread should ask for +a **generic (non-`simfile.*`) capabilities profile** plus `--json` on +`compile`/`up`(image mode). Those are the two Spawnfile-side items that would +let Simfile's preflight stop being a workaround. + +## 5. Ecosystem CLI output steer + +Both CLIs are half clean in the same way: some commands emit versioned JSON +receipts, others print human prose a consumer must scrape. Simfile's composed +`run` and `recover` emit receipts and `validate` has `--json`, but local `run` +prints `wrote run to `. Spawnfile's `target …` commands are +receipt-perfect while `compile`/`build`/`run`/`publish`/`validate`/`auth` are +prose-only. Moltnet is meanwhile developing the curated human rendering +language (banner on `init`, ✓ step lines, aligned annotations, `Next:` block). + +**Principle: every command builds a versioned receipt object first, then +renders it.** + +- `--json` → the receipt verbatim: one self-identifying versioned object + (`spawnfile.*.v1` / `simfile.*.v1`) on stdout; diagnostics on stderr; exit + `0` success (typed states like `not-present`/`pending` count as success), + `2` bad input, `1` operation failure; never secrets. +- default → human rendering *derived from that same receipt*: ✓ lines per + step, `Next:` hints, banner only on identity moments. Derivation is what + keeps the human and machine views from drifting. +- Commands that are already JSON-always (Spawnfile `target …`, Simfile + composed run/recover) stay JSON-always. + +**Phasing:** now, align structure everywhere (receipt-first, `--json`, stream +and exit-code discipline, plain ✓/`Next:` skeleton); later, once the Moltnet +rendering language is fully curated, apply it across all three repos as a +renderer swap — no command logic changes. + +**Home for the convention:** not `spawnfile/specs/` — those specify the +Spawnfile format and contracts, not tooling. It belongs in the ecosystem root +guide's shared conventions (the `CLAUDE.md` above the repos), naming Moltnet +as the reference implementation for the human layer; each repo then applies +it in its own `AGENTS.md`. + +## 6. Recommendation + +1. Rewrite PLAN.md as a short remaining-work list (§2 fixtures cleanup, the + two `example:*` scripts, naming reconciliation) and mark the rest done. +2. Land the working tree in reviewable commits — this is the only P1. +3. Add Simfile's own output alignment to the plan: `--json` receipt for local + `run` (and `observe` if it prints), receipt-first structure in human mode. +4. Send the Spawnfile thread three asks: a generic (non-`simfile.*`) + capability profile; `up --json` fixed in image mode; adoption of the §5 + output contract on `compile`/`build`/`run`/`publish`/`validate`/`auth` + (phased, `compile` first — it's the one Simfile scrapes today). +5. Put the output convention in the ecosystem root guide's shared + conventions; each repo applies it in its own `AGENTS.md`. Keep Moltnet + iterating the rendering layer independently. diff --git a/README.md b/README.md index f49a7f2..01c492f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

npm downloads - node + node MIT website

@@ -18,8 +18,9 @@ Deterministic and replayable. Observer-tier by design: Simfile authors and obser ## Contents -- [Install](#install) -- [Quick start](#quick-start) +- [Source-clone quick start](#source-clone-quick-start) +- [Develop with Spawnfile](#develop-with-spawnfile) +- [Installed package](#installed-package) - [What you can see](#what-you-can-see) - [The world](#the-world) - [Example](#example) @@ -27,27 +28,119 @@ Deterministic and replayable. Observer-tier by design: Simfile authors and obser - [Repo guide](#repo-guide) - [Docs](#docs) -## Install +## Source-clone quick start + +This is the current working path for a new contributor. It runs Simfile's +checked-in deterministic mechanics example locally; it needs **no Spawnfile, +Docker, GPU, credentials, or sibling checkout**. + +```bash +git clone https://github.com/noopolis/simfile.git +cd simfile +npm ci +npm run build +npm run example:local +``` + +The command runs the dream mechanics from the canonical +`examples/jungian-dialogue/Simfile` in bounded local mode and prints its +unique `runs/example-local-...` directory. Local mode does not start the two +agents; use the composed path below to see their conversation. +Use `node dist/cli/index.js view ` to inspect it. These +source-clone commands invoke the checkout's freshly built CLI directly, with +no registry or global command resolution. Node.js >=22.19.0 is required. + +## Develop with Spawnfile + +Spawnfile is a separate product and repository. A Simfile contributor can +install any Spawnfile checkout into an isolated, ignored tool root; the two +repositories do not need to be siblings and Simfile never imports Spawnfile +source. + +Install a Spawnfile source checkout or an exact published package into an +isolated tool root. If using a checkout, its absolute path is explicit: ```bash -npm install simfile -simfile --help +git clone https://github.com/noopolis/simfile.git +git clone https://github.com/noopolis/spawnfile.git /absolute/path/to/spawnfile + +cd simfile +npm ci +npm run build +npm run dev:spawnfile:setup -- --source /absolute/path/to/spawnfile +npm run dev:spawnfile:check ``` -Node.js 22+. +Setup copies that exact Spawnfile checkout into a private temporary stage, +runs `npm ci`, builds and packs only the staged copy, then installs the tarball +under `.simfile-dev/spawnfile/`. It does not mutate the source checkout or use +a global CLI, `../spawnfile`, or a `file:` dependency. The check validates +`examples/jungian-dialogue/org/Spawnfile` through the isolated +parser/compiler front end and records Simfile's own +`simfile.spawnfile-public-capability-probe.v1`. + +The probe uses only generic documented Spawnfile CLI surfaces: `--version`, +`capabilities --json` when the installed release supports it, and legacy +command `--help` only as a fail-closed fallback. It never calls `spawnfile +compatibility --profile simfile.*` or asks Spawnfile to recognize +Simfile-specific profiles. Missing capabilities are explicit blockers, never +machine defaults or private helpers. + +For a published release, use `npm run dev:spawnfile:setup -- --package +spawnfile@` instead. `npm run +dev:spawnfile:status` shows the isolated executable and its last probe result. +For a prepacked release artifact, avoid registry resolution entirely with +`npm run dev:spawnfile:setup -- --artifact /absolute/release.tgz --sha256 +`; setup verifies and records that physical artifact origin. + +The bounded lifecycle/replay example is a credential-free Jungian dialogue: + +```bash +npm run example:composed -- --context +``` -## Quick start +Run it only after `dev:spawnfile:check` reports the composed portion of +`simfile.spawnfile-public-capability-probe.v1` as ready. The admitted contract +is the exact Spawnfile 0.1.17 public 43-command set. Before Simfile opens +support state or invokes a target mutation, the runner pins the isolated +executable and proves that the explicitly selected context resolves to a local +Unix-socket, named-pipe, or file-descriptor endpoint. Older or drifted +contracts fail closed. + +The analyst observes a black door, tarnished mirror, and lost child through a +bearer-authenticated world sense, then a scripted five-message mention chain +runs through the Spawnfile-managed Moltnet room. The words are an authored +screenplay, clearly labeled as such; the exported room messages are genuine +engine outputs from the run. The invocation pins `--mode +lifecycle-replay-smoke` and a unique run ID/output. Its versioned receipt +proves lifecycle completion and exact replay and reports live agent-action +evidence as `not_evaluated`. The former one-agent lifecycle regression remains +available only as `npm run example:internal-smoke -- --context `. +Omitting `--mode` from an ordinary linked run +retains the strict live verdict, including the requirement for an authenticated +applied action from every principal. + +## Installed package + +For a project-installed package, start with `npm exec -- simfile --help`; for a global install, use `simfile --help`. Then +use the commands that match the installed release's documentation. The +source-clone command above intentionally uses the checkout's freshly built CLI +and checked-in example. + +## Commands ```bash simfile validate ./Simfile.yaml # check a world -simfile run ./Simfile --view # run a linked world + organization, then watch it simfile run ./Simfile --local --ticks 200 # bounded mechanics-only diagnostic simfile view runs/ # replay a sealed run — scrub, descend, watch spread simfile view --state .sim # watch a live world simfile observe runs/ # reconcile causal chains + measure spread → report.json ``` -For a linked project, `simfile run` performs lifecycle composition: it starts +Linked composition is not the source-clone quick start. Run the standalone +setup and public capability probe first. A linked project may use `simfile run` +only when the composed probe is ready; it performs +lifecycle composition by starting the world paused on `simfile.world-sidecar-runtime.v1`, delegates organization lifecycle to Spawnfile's public CLI, attests the topology and any separately manifested capability extensions, and atomically activates both owners. Tick 1 @@ -56,9 +149,6 @@ wake autonomous runtimes; Simfile never selects, wakes, invokes, polls, or waits for cognition. Observation recommendations are optional pull-only sense metadata, never deliveries or wake authority. -A future `simfile dev` wrapper may add watch/debug ergonomics, but must reuse -this lifecycle rather than own another one. - The live receipt also binds Spawnfile's pinned `spawnfile.moltnet-release-identity.v1`: architecture, asset digest, release version, source revision, and the exact `pi-bridge` capability. Unpinned @@ -68,7 +158,7 @@ version, source revision, and the exact `pi-bridge` capability. Unpinned `simfile view ` serves a local web app that turns a run into an instrument, not a screensaver: -- **Scrub the whole run.** One causally-ordered timeline — play, rewind, step. The world map, the room chat, and every agent's memory all move together off a single cursor. +- **Scrub the whole run.** One causally-ordered timeline — play, rewind, step. The world map, the room chat, and every agent's memory all move together off a single cursor. A sealed run with participant speech opens on Conversation; Map is the deterministic fallback, and either choice is deep-linkable. - **Watch a meme spread.** A secret seeded *only* in one agent's private memory surfaces in conversation and reaches others on its own; the timeline lights up where it lands, with reach, latency, and match fidelity — re-derived from the sealed run, never faked. - **Descend into a mind.** Click an agent that is itself an org (a Jungian self) and drop into its inner council: the archetypes deliberate, the representative synthesizes and answers out. Recursion by data — an agent *is* an organization you haven't opened yet. - **Per-element storylines.** An agent, a room, a memory bank, a variable — each has its own timeline you can open, all linked to the one global cursor. @@ -93,50 +183,40 @@ Domain concepts live in fixtures, never in schema keys. ## Example +The canonical composed project is [`examples/jungian-dialogue`](examples/jungian-dialogue/README.md): + ```yaml simfile_version: "0.1" -name: autonomous-office-world -spawnfile: ./Spawnfile +name: jungian-dialogue +spawnfile: ./org/Spawnfile clock: - seed: office-run-014 - tick: 20s - sim_per_tick: 10m - phases: { morning: "07:00", workday: "09:00", evening: "18:00", night: "22:00" } - -variables: - filing_pressure: - scope: room:office-floor:case-warroom - initial: 0.4 - range: 0..1 - -generators: - deadline_ramp: - kind: deterministic - when: { phase: workday } - variable: filing_pressure - delta: 0.02 - -rules: - deadline_bites: - when: { variable: filing_pressure, above: 0.85 } - do: - - action: moltnet:message - to: room:office-floor:case-warroom - content: "Filing pressure crossed the deadline threshold." - -markers: - tenant_name: - text: [Rosa Delgado] - mode: containment - scopes: [room:office-floor:case-warroom] - -probes: - deadline_observed: - when: { event: world.message, target: room:office-floor:case-warroom } - expect: { at_least: 1 } + seed: jungian-dialogue-seed + tick: 1s + sim_per_tick: 1s + +dynamics: + module: ./world/provider.mjs + config: + black_door: 1 + tarnished_mirror: 0.9 + lost_child: 0.8 + dread: 0.72 + +world: + id: dream-consulting-room + grants: + analyst: { entity: entity:analyst, senses: [sense:dream], affordances: [] } + daimon: { entity: entity:daimon, senses: [sense:dream], affordances: [] } + +world_sidecar: + binding: ./binding.mjs + composer: ./world/composer.mjs ``` +The linked Spawnfile declares the analyst and daimon in one consulting room; +the example README explains the story, evidence boundary, and exact commands. + ## Observe `simfile observe ` reconciles every authority's causal stream — Moltnet, Daimon, Mneme, and the world kernel — into one honest verdict: complete vs. incomplete causal chains, per-agent memory, failures, and, for a seeded world, spread measurement (channel · reach · latency · fidelity) re-derived from sealed artifacts. Ordering is causal, never wall-clock; a missing link is reported, never stitched. @@ -148,6 +228,7 @@ src/schema v0.1 world schema + validator src/runtime deterministic world kernel (clock, generators, rules, markers, probes) src/observe causal reconciliation + spread measurement src/view + web the run-replay viewer (React), served by `simfile view` +scripts/ bounded contributor tooling, including isolated Spawnfile setup docs/ design + research (DESIGN, VIEW_DESIGN, VIEW_STYLEGUIDE, …) ``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 26494b5..29f9e35 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,5 +1,11 @@ # Simfile Design +> **Status:** This document contains product direction as well as implemented +> behavior. Command blocks naming `plan`, `explain`, `inspect`, `probes`, +> `report`, `doctor`, `status`, `clock`, `ledger`, or `runs` are proposals, not +> current CLI instructions. The implemented command/exit contract is the +> website's `reference/cli` page and `simfile --help` from the built checkout. + Simfile is to worlds what Spawnfile is to organizations: it declares, derives, and measures; it never interprets. diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index 1ba16e5..3f30d14 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -179,7 +179,7 @@ scopes/paths; treat per-agent attribution as the weaker claim. ## 6. Space, objects, possession, belonging & access (second run) -Run 2026-07-07 via the deep-research skill on the LeDeluge workstation +Run 2026-07-07 via a hosted research session (ChatGPT · Extra High, headed browser); full report with citation anchors in `research/2026-07-07/space-objects-access.md` (+ `.chatgpt.reply.html`). diff --git a/docs/SITE_DESIGN.md b/docs/SITE_DESIGN.md index e76e7d9..59f6ce0 100644 --- a/docs/SITE_DESIGN.md +++ b/docs/SITE_DESIGN.md @@ -1,18 +1,18 @@ # Simfile Documentation Site: Design Spec and Docs IA -Canonical spec for the Simfile public site (`ecosystem/simfile/website/`): the +Canonical spec for the Simfile public site (`website/`): the landing page, the visual language, the navigation, and the full documentation information architecture with per-page content outlines. Sources of truth, in priority order: -1. `../../ECOSYSTEM-DESIGN.md`: the shared Noopolis design system. This spec - applies it; it never overrides it. +1. The shared Noopolis design system: this spec applies its conventions; it + never overrides them. 2. `DESIGN.md` and `VIEW_DESIGN.md`: what the product is. No page may invent schema keys, commands, or viewer behavior that these documents do not back. -3. `../moltnet/website/`: the reference implementation of the site foundation. - Simfile's site must read as a first-class sibling: same structure, same - polish, same component grammar, different accent and content. +3. The established Noopolis site foundation: Simfile's site must read as a + first-class sibling, with the same structure, polish, and component grammar + but a different accent and content. Brand facts, confirmed (encode, do not revisit): @@ -98,12 +98,12 @@ below, in the proof frame, exactly where Moltnet animates. - Left: a short Simfile YAML excerpt (clock + one rule), highlight grammar with violet keywords and prompts, gray-1 values, gray-3 hints. One animated span: the `seed:` value typewrites as the pills cycle. - - Center arrow: `$ simfile run ./Simfile --ticks 144` with the compiling + - Center arrow: `$ simfile run ./Simfile --local --ticks 144` with the compiling shimmer on cycle. - Right: the run-record checklist revealed item by item: `✓ ledger.jsonl` · `✓ telemetry.json` · `✓ report.json` · `✓ viewer-trace.json`, then the run line `viewer at :18787`. - - Pills under the left pane cycle **fixtures, not runtimes** (Simfile's + - Pills under the left pane cycle through **fixture examples** (Simfile's variable is the world, Moltnet's was the agent system): `office-world`, `repeated-dilemma`, `tiny-world`. Clicking a pill swaps the YAML excerpt name/seed and replays the reveal. Every pill must correspond to a fixture @@ -273,7 +273,7 @@ publish; downgrade any claim the code does not back): - **Schema**: v0.1 kernel schema and validator, implemented and stable. Domain-noun lint enforced. Lexical shorthands (`range: 0..1`, durations) expand in the lexer. -- **Runtime**: seeded finite runs via `simfile run --ticks N`, implemented. +- **Runtime**: seeded finite runs via `simfile run --local --ticks N`, implemented. Deterministic mechanical stream, sealed run records (`manifest.yaml`, `ledger.jsonl`, `telemetry.json`, `report.json`, `viewer-trace.json`). Mechanical probes and marker scanning evaluate in the run report; @@ -356,7 +356,7 @@ path is always current. **`quickstart.md`** (rewrite) Purpose: first success in five minutes using only implemented commands. Sections: `Create a World` (the clock-only `tiny-world`) · `Validate It` · -`Run It` (`simfile run ./Simfile --ticks 144 --out runs/first`) · `Open the +`Run It` (`simfile run ./Simfile --local --ticks 144 --out runs/first`) · `Open the Viewer` (`simfile view runs/first`) · `What Just Happened` (the run record files, one line each) · `Next Steps` (first-world guide, office guide). Status line: every command on this page is implemented; no planned surface @@ -415,11 +415,12 @@ Sections: `The Boundary` (Simfile never re-parses Spawnfile YAML) · `The Resolved Graph Report` (`spawnfile compile --report-json`, then `--spawnfile-report` on validate/run) · `Binding Checks` (agents, teams, rooms verified against the report) · `The World as a Participant` (`@world` -credential, no private wake path) · `A Full Local Flow` (compile, up, run, -view) · `Planned: simfile dev` (badge `planned`, v2; prints its Spawnfile +credential, no private wake path) · `A Full Composed Flow` (compile, explicit +operator inputs, linked run, view) · `Planned: simfile dev` (badge `planned`, v2; prints its Spawnfile command first). -Status line: `--spawnfile-report` is implemented on `validate` and `run`; -`plan` and `dev` are planned. +Status line: `--spawnfile-report` is implemented on `validate` and local +`run --local --ticks`; linked composition requires a project binding and +explicit operator inputs. `plan` and `dev` are planned. **`guides/viewer.md`** (rewrite of `guides/live-viewer.md`) Purpose: teach someone to drive the instrument and read it critically. @@ -492,10 +493,10 @@ validates against the current validator (CI-checkable). **`reference/cli.md`** (rewrite) Purpose: every command and flag, statused; the honest-status rule made into a table. -Sections: `Implemented` (`validate` with `--json`/`--spawnfile-report`; `run` -with `--ticks`/`--out`/`--seed`/`--run-id`/`--moltnet-artifact`/ -`--spawnfile-report`; `view` with `--state`/`--port`/`--no-open` and the -run-dir form; exact output files listed) · `Exit Codes` · `Planned: v1 +Sections: `Implemented` (`validate` with `--json`/`--spawnfile-report`; local +`run` with `--local --ticks`/`--out`/`--seed`/`--run-id`/`--moltnet-artifact`/ +`--spawnfile-report`; linked `run`; `recover`; `view` with `--state`/`--port`/ +`--no-open` and the run-dir form; exact output files listed) · `Exit Codes` · `Planned: v1 Planning` (`plan`, `diff`, `doctor`, `inspect`, `explain`) · `Planned: v2 World Runtime` (`status`, `clock`, `ledger`, `probes --follow`, `report --collect`, `runs`, `dev`) · `Planned: v3 Governance` (`propose`, diff --git a/docs/SYSTEMS_VIEW.md b/docs/SYSTEMS_VIEW.md index 24ad10a..4fc2758 100644 --- a/docs/SYSTEMS_VIEW.md +++ b/docs/SYSTEMS_VIEW.md @@ -1,5 +1,10 @@ # Simfile Systems View +> **Status:** This is a design view, not an executable quickstart. Its staged +> `plan`, `probes`, `report`, and long-running `--state` lifecycle commands are +> proposals. Use the source-clone commands in `README.md` and the website CLI +> reference for behavior that ships. + simfile design v0.1 · configuration reference · companion to the systems view This is the curated markdown form of the raw systems-view capture in `here.txt`. diff --git a/docs/VIEW_DESIGN.md b/docs/VIEW_DESIGN.md index 9c82566..ee392b9 100644 --- a/docs/VIEW_DESIGN.md +++ b/docs/VIEW_DESIGN.md @@ -1,5 +1,9 @@ # Simfile Viewer Design +> **Status:** This document includes proposed skin, live-tail, and Moltnet +> attachment commands. The implemented surface is limited to the flags shown by +> `simfile view --help`; see the website viewer guide for current behavior. + The viewer is to the run record what the ruler is to length: a cognitive tool. Rulers, Cartesian planes, and Feynman diagrams earn their keep by converting a class of inference into a class of perception — they are diff --git a/docs/VIEW_STYLEGUIDE.md b/docs/VIEW_STYLEGUIDE.md index 9a9f550..29dbb49 100644 --- a/docs/VIEW_STYLEGUIDE.md +++ b/docs/VIEW_STYLEGUIDE.md @@ -7,9 +7,9 @@ grading rubric for the B49 render acceptance gate: section 7 is a checklist of objective assertions a Playwright run can evaluate against screenshots, the DOM, and `viewer-trace.json`. -Shared foundation (canvas, neutral ramp, fonts, accent trio) is defined in -`/ECOSYSTEM-DESIGN.md` and is not restated here except where the viewer binds it -to data semantics. GlyphCSS (`@glyphcss/*@^0.1.0`, the frozen published +Shared foundation (canvas, neutral ramp, fonts, accent trio) follows the +Noopolis design system and is not restated here except where the viewer binds +it to data semantics. GlyphCSS (`@glyphcss/*@^0.1.0`, the frozen published dependency) is the map substrate; the local GlyphCSS checkout is reference-only and never a build input. diff --git a/examples/AGENTS.md b/examples/AGENTS.md new file mode 100644 index 0000000..2463dcb --- /dev/null +++ b/examples/AGENTS.md @@ -0,0 +1,13 @@ +# Example Project Guide + +This directory contains complete, user-facing projects that can be copied or +run from a clean Simfile source checkout. Tests must consume these exact files; +do not keep a second fixture copy of an example. + +- Examples use only documented `simfile/*` exports, emitted runtime surfaces, + project-local files, and Node built-ins where the public authoring layer + explicitly permits them. +- Keep every external tool path and target choice explicit. Never assume a + sibling checkout, global installation, host name, GPU, or credential. +- An example must state any external compatibility blocker without replacing + missing capabilities or fabricating successful evidence. diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/AGENTS.md b/examples/composed-development/AGENTS.md new file mode 100644 index 0000000..a697612 --- /dev/null +++ b/examples/composed-development/AGENTS.md @@ -0,0 +1,19 @@ +# Composed Development Example Guide + +This example is the smallest standalone linked Simfile/Spawnfile project used +to prove source-checkout authoring, world-sidecar preparation, paused readiness, +and exact mechanics replay without Docker. + +- Example JavaScript may import only documented `simfile/*` package exports, + Node built-ins, local example modules, or the emitted sidecar + `./entrypoint.mjs` / `./provider.mjs` surfaces. +- Never import `src/**`, a sibling checkout, a global installation, or a + machine-specific path. +- The scripted agent has no world-action ingress. Keep the evidence honest: + this example does not claim or fabricate a live agent action. +- Every controller loop must have a fixed terminal tick and every close path + must settle without polling or an unbounded timer. +- Keep the example README explicit about what the smoke does and does not + evaluate. +- `binding.mjs` owns the public binding; `binding-world.mjs` holds its + deterministic kernel/replay fixture and evidence map. diff --git a/examples/composed-development/CLAUDE.md b/examples/composed-development/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/composed-development/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/README.md b/examples/composed-development/README.md new file mode 100644 index 0000000..1a34abb --- /dev/null +++ b/examples/composed-development/README.md @@ -0,0 +1,77 @@ +# Composed development lifecycle/replay smoke + +This is a standalone source-checkout example for the linked Simfile + +Spawnfile development path. It contains one deterministic scripted agent, no +Moltnet surface, a world provider, a public project binding, an emitted-surface +composer, finite world control, evidence mappings, and an exact replay adapter. + +The contract test consumes this exact example and prepares the runnable sidecar bundle without +Docker. It verifies paused readiness, the one principal/capability binding, +the complete evidence mapping, and exact mechanics replay to the fixed +terminal tick. + +This example is deliberately a **lifecycle/replay smoke**. The scripted engine +has no authenticated world ingress, so live agent action is **not evaluated**. +The example records an empty accepted-action stream and never invents an agent +action. Run it from a Simfile source checkout only after +`dev:spawnfile:check` reports Simfile's +`simfile.spawnfile-public-capability-probe.v1` as composed-ready: + +```bash +npm run example:composed -- --context +``` + +The runner uses the explicit `--mode lifecycle-replay-smoke` command mode plus +a unique run ID and output. It pins the exact installed Spawnfile 0.1.17 public +contract and proves that the requested context is a local endpoint before +starting the lifecycle. `simfile.composed-lifecycle-replay-smoke-receipt.v1` +proves the lifecycle and exact replay only, reports live agent-action +evidence as `not_evaluated`, and carry no live simulation verdict. A normal +linked run without that mode still requires an authenticated, applied action +from every principal and will not treat this example's empty stream as success. + +The probe uses only generic documented Spawnfile CLI surfaces. It prefers +`spawnfile capabilities --json`, validates its complete generic lifecycle +contract set, and falls back to help only to explain an older release. It never +calls `spawnfile compatibility --profile simfile.*`. Older, remote, +default-selected, or contract-drifted installations stop before lifecycle +mutation. + +## Project map + +```text +Simfile # clock, seed, linked organization, and terminal tick +binding.mjs # public composed-project binding +binding-world.mjs # deterministic kernel, replay adapter, and evidence map +org/ # minimal scripted Spawnfile organization +world/composer.mjs # deterministic sidecar bundle authoring +world/provider.mjs # finite controller and evidence emission +world/evidence.mjs # evidence/replay mapping +world/surface.mjs # public world surface declaration +harness/scripted-engine.mjs # credential-free scripted organization engine +``` + +## Modes and outputs + +`npm run example:local` is the runnable, mechanics-only path. It invokes the +built CLI with `--local --ticks 4` and allocates a unique +`runs/example-local-` directory. Inspect it with: + +```bash +node dist/cli/index.js view runs/example-local- +``` + +`npm run example:composed -- --context ` runs the linked +`--mode lifecycle-replay-smoke` invocation with its own unique run ID and +output after local-endpoint proof. The sealed output contains the world and +organization evidence, terminal +signal, and replay proof; the smoke receipt will still say +`live_action_evidence: not_evaluated`. + +For recovery, retain the printed `simfile recover --journal ...` command if a +lifecycle reports one. Do not delete its private support root +or attempt manual target cleanup: Spawnfile owns target and credential +resources. To convert this example to a real engine, replace the scripted +member in `org/` with an explicitly profiled engine, preserve the declared +world bindings, and use normal linked `simfile run` mode; every required +principal must then contribute an authenticated accepted action. diff --git a/examples/composed-development/Simfile b/examples/composed-development/Simfile new file mode 100644 index 0000000..9f52a30 --- /dev/null +++ b/examples/composed-development/Simfile @@ -0,0 +1,26 @@ +simfile_version: "0.1" +name: composed-development-lifecycle-replay-smoke + +spawnfile: ./org/Spawnfile + +clock: + seed: composed-development-seed + tick: 1s + sim_per_tick: 1s + +dynamics: + module: ./world/provider.mjs + config: + initial: 0 + +world: + id: development + grants: + smoke: + entity: entity:counter + senses: [sense:value] + affordances: [] + +world_sidecar: + binding: ./binding.mjs + composer: ./world/composer.mjs diff --git a/examples/composed-development/binding-world.mjs b/examples/composed-development/binding-world.mjs new file mode 100644 index 0000000..9d8be19 --- /dev/null +++ b/examples/composed-development/binding-world.mjs @@ -0,0 +1,108 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadDynamicsSession } from "simfile/dynamics"; +import { parseSimfileSource } from "simfile/schema"; +import { + captureWorldCheckpoint, + createWorldServiceContract, +} from "simfile/world-artifact"; +import { + composeWorldRuntimeInput, + createWorldRuntime, + parseWorldCheckpoint, +} from "simfile/world"; +import { parseWorldSurfaceDefinition } from "simfile/world-surface"; + +import { probeBytes, terminalStateBytes } from "./world/evidence.mjs"; +import { createWorldSurfaceDefinition } from "./world/surface.mjs"; + +const exampleRoot = path.dirname(fileURLToPath(import.meta.url)); +export const packageRoot = path.resolve(exampleRoot, "../.."); +export const exampleSimfile = path.join(exampleRoot, "Simfile"); +export const exampleSpawnfile = path.join(exampleRoot, "org", "Spawnfile"); +export const terminalTick = 4; +export const worldInstanceId = "composed-development-world"; + +export const serviceContract = createWorldServiceContract({ + adapters: { json: "WorldJsonServer", mcp: "WorldMcpProtocolServer" }, + capability_manifest: "simfile.capability-manifest.v1", + dynamics_provider: "simfile.dynamics-provider.v1", + handler: "WorldRequestHandler", + operations: ["status"], + spawnfile_receipts: ["spawnfile.target-resource.receipt.v1"], + world_act_request: "simfile.world-act-request.v1", + world_bindings: "simfile.world-bindings.v1", + world_checkpoint: "simfile.world-checkpoint.v1", + world_runtime: "WorldRuntime", + world_surface: "simfile.world-surface.v1", +}); + +const principalResolver = Object.freeze({ + resolveParticipant: (principal) => principal === "agent:smoke" ? "smoke" : undefined, + resolvePrincipal: (participant) => participant === "smoke" ? "agent:smoke" : undefined, +}); + +export const loadKernel = async (runId, seed) => { + const parsed = parseSimfileSource(await readFile(exampleSimfile, "utf8"), { + path: exampleSimfile, + }); + if (parsed.simfile.world === undefined) { + throw new TypeError("composed development world declaration is missing"); + } + const session = await loadDynamicsSession(parsed.simfile, { seed, simfilePath: exampleSimfile }); + if (session === undefined) { + throw new TypeError("composed development dynamics declaration is missing"); + } + const runtimeInput = composeWorldRuntimeInput({ + principalResolver, runId, session, + surfaceRegistry: parseWorldSurfaceDefinition(createWorldSurfaceDefinition()), + world: parsed.simfile.world, worldInstanceId, + }); + return { checkpoint: captureWorldCheckpoint(createWorldRuntime(runtimeInput)), + parsed, runtimeInput, session }; +}; + +export const replayAdapter = (runId, seed) => Object.freeze({ + async restore(rawCheckpoint) { + const checkpoint = parseWorldCheckpoint(rawCheckpoint); + const kernel = await loadKernel(runId, seed); + kernel.session.restore(checkpoint.dynamics); + return Object.freeze({ session: kernel.session }); + }, + async inject() { + throw new TypeError("composed development replay accepts no recorded actions"); + }, + async finish(state) { + const remaining = terminalTick - state.session.nextTick; + if (!Number.isSafeInteger(remaining) || remaining < 0) { + throw new TypeError("composed development replay starts beyond its terminal tick"); + } + for (let step = 0; step < remaining; step += 1) { + const before = state.session.nextTick; + state.session.step(); + if (state.session.nextTick !== before + 1) { + throw new TypeError("composed development replay made invalid tick progress"); + } + } + if (state.session.nextTick !== terminalTick) { + throw new TypeError("composed development replay exceeded its terminal tick"); + } + return Object.freeze({ probe: probeBytes(runId, terminalTick), + terminal_state: terminalStateBytes(state.session.snapshot()), terminal_tick: terminalTick }); + }, +}); + +export const evidenceArtifacts = Object.freeze([ + { path: "actions/accepted.json", role: "accepted-action", source: "actions/accepted-strategic-actions.json" }, + { path: "actions/results.jsonl", role: "action-result", source: "actions/results.jsonl" }, + { path: "identity/principals.json", role: "identity", source: "projections/principals.json" }, + { path: "probes/lifecycle-replay.json", role: "probe", source: "projections/lifecycle-replay-probe.json" }, + { path: "replay/accepted-actions.jsonl", role: "accepted-action", source: "actions/replay-accepted-actions.jsonl" }, + { path: "replay/expected.json", role: "terminal", source: "projections/replay-expected.json" }, + { path: "replay/initial-checkpoint.json", role: "world-checkpoint", source: "checkpoints/initial.json" }, + { path: "replay/terminal-checkpoint.json", role: "world-checkpoint", source: "checkpoints/terminal.json" }, + { path: "world/frames.jsonl", role: "world-frame", source: "projections/frames.jsonl" }, + { path: "world/terminal-state.json", role: "provenance", source: "projections/terminal-state.json" }, +]); diff --git a/examples/composed-development/binding.mjs b/examples/composed-development/binding.mjs new file mode 100644 index 0000000..8059540 --- /dev/null +++ b/examples/composed-development/binding.mjs @@ -0,0 +1,129 @@ +import { realpath } from "node:fs/promises"; + +import { createComposedProjectBinding } from "simfile/compose"; +import { + createWorldSidecarAuthoringBinding, + prepareAuthoredWorldSidecarBundle, + WORLD_DECISION_CLAIM_CAPABILITY, + worldReadinessHashes, + worldReadinessIdentity, +} from "simfile/world-artifact"; + +import { + evidenceArtifacts, + exampleSimfile, + exampleSpawnfile, + loadKernel, + packageRoot, + replayAdapter, + serviceContract, + terminalTick, + worldInstanceId, +} from "./binding-world.mjs"; + +export const composedProjectBinding = createComposedProjectBinding({ + async prepareComposedProject(input) { + const [actualSimfile, actualSpawnfile, expectedSimfile, expectedSpawnfile] = + await Promise.all([ + realpath(input.simfile_path), realpath(input.spawnfile_path), + realpath(exampleSimfile), realpath(exampleSpawnfile), + ]); + if (actualSimfile !== expectedSimfile || actualSpawnfile !== expectedSpawnfile) { + throw new TypeError("composed development project paths are invalid"); + } + let kernel; + const authored = await prepareAuthoredWorldSidecarBundle({ + binding: createWorldSidecarAuthoringBinding({ + composer: { + entry_point: "examples/composed-development/world/composer.mjs", + }, + dependency_root: packageRoot, + evidence_root: input.evidence_root, + network: { dns_alias: "world", internal_port: input.internal_port }, + secrets: { + declarations: [{ + name: "world_token", + principal: "agent:smoke", + scope: "world", + }], + root: input.secret_root, + }, + service_contract: serviceContract, + simfile_path: exampleSimfile, + source_root: packageRoot, + }), + async create_composer_settings(context) { + kernel = await loadKernel(input.run_id, input.seed); + if (kernel.session.buildReceipt.receiptSha256 + !== context.provider.receipt.receiptSha256) { + throw new Error("composed development provider receipt drift"); + } + return { + defines: { + __BUILD_RECEIPT__: JSON.stringify(context.provider.receipt), + __EVIDENCE_ROOT__: JSON.stringify(input.evidence_root), + __PROVIDER_CONFIG__: JSON.stringify(context.provider.config), + __PROVIDER_PROVENANCE__: JSON.stringify(kernel.session.provenance), + __RUN_ID__: JSON.stringify(input.run_id), + __SEED__: JSON.stringify(input.seed), + __SIM_SECONDS_PER_TICK__: JSON.stringify( + kernel.checkpoint.dynamics.sim_seconds_per_tick, + ), + __TERMINAL_TICK__: JSON.stringify(terminalTick), + __WORLD__: JSON.stringify(kernel.parsed.simfile.world), + __WORLD_INSTANCE_ID__: JSON.stringify(worldInstanceId), + }, + identity: { + build_receipt: context.provider.receipt, + configuration: context.provider.config, + provider_provenance: kernel.session.provenance, + }, + }; + }, + }); + if (kernel === undefined || kernel.runtimeInput.capabilityManifests.length !== 1) { + throw new Error("composed development capability preparation is incomplete"); + } + const identity = worldReadinessIdentity(kernel.checkpoint); + const hashes = worldReadinessHashes(kernel.checkpoint); + const manifestDigest = identity.capability_manifest_digests[0]; + return { + base_image_config_digest: input.base_image_config_digest, + bundle: authored.bundle, + credentials: [{ + bytes: 32, + env: "SIMFILE_WORLD_TOKEN", + kind: "generated-token", + name: "world_token", + }], + evidence_artifacts: evidenceArtifacts, + platform: input.platform, + readiness_expectation: { + artifact_digest: authored.bundle.manifest.artifact.service_digest, + bundle_digest: authored.bundle.manifest.digest, + capabilities: [{ + identity: WORLD_DECISION_CLAIM_CAPABILITY, + manifest_digest: manifestDigest, + }], + capability_manifest_digests: identity.capability_manifest_digests, + mechanics_sha256: hashes.mechanics, + normalized_checkpoint_sha256: hashes.normalized_checkpoint, + run_id: identity.run_id, + world_instance_id: identity.world_instance_id, + }, + replay_adapter: replayAdapter(input.run_id, input.seed), + secret_bindings: [{ + credential_name: "world_token", + name: "world_token", + scope: "world", + }], + terminal_tick: terminalTick, + world_members: [{ + capability_manifest: kernel.runtimeInput.capabilityManifests[0].manifest, + id: "smoke", + principal_id: "agent:smoke", + token_credential_name: "world_token", + }], + }; + }, +}); diff --git a/examples/composed-development/harness/AGENTS.md b/examples/composed-development/harness/AGENTS.md new file mode 100644 index 0000000..c5a1848 --- /dev/null +++ b/examples/composed-development/harness/AGENTS.md @@ -0,0 +1,5 @@ +# Scripted Harness + +Keep this engine dependency-free, deterministic, and limited to the Pi +scripted-engine argv/stdout contract. It must not call the world or fabricate +world-action evidence. diff --git a/examples/composed-development/harness/CLAUDE.md b/examples/composed-development/harness/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/composed-development/harness/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/harness/scripted-engine.mjs b/examples/composed-development/harness/scripted-engine.mjs new file mode 100755 index 0000000..2e49472 --- /dev/null +++ b/examples/composed-development/harness/scripted-engine.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +process.stdout.write("Composed development lifecycle smoke completed.\n"); diff --git a/examples/composed-development/org/AGENTS.md b/examples/composed-development/org/AGENTS.md new file mode 100644 index 0000000..59317e8 --- /dev/null +++ b/examples/composed-development/org/AGENTS.md @@ -0,0 +1,8 @@ +# Smoke Organization + +This one-agent organization exists only to exercise standalone Spawnfile +compilation and composed lifecycle wiring. It intentionally declares no +Moltnet network or agent world-action surface. + +Do not claim that the scripted turn produced a world action. The associated +Simfile example evaluates lifecycle/readiness/replay only. diff --git a/examples/composed-development/org/CLAUDE.md b/examples/composed-development/org/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/composed-development/org/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/org/Spawnfile b/examples/composed-development/org/Spawnfile new file mode 100644 index 0000000..b5a664e --- /dev/null +++ b/examples/composed-development/org/Spawnfile @@ -0,0 +1,15 @@ +spawnfile_version: "0.1" +kind: team +name: composed-development-smoke +description: "One-agent zero-auth scripted organization for lifecycle/replay development checks." + +shared: + workspace: + docs: + system: TEAM.md + +members: + - id: smoke + ref: ./agents/smoke + +mode: swarm diff --git a/examples/composed-development/org/TEAM.md b/examples/composed-development/org/TEAM.md new file mode 100644 index 0000000..34dcef4 --- /dev/null +++ b/examples/composed-development/org/TEAM.md @@ -0,0 +1,5 @@ +# Composed Development Smoke Team + +Run one deterministic scripted agent for lifecycle integration checks. The +agent has no communication surface and no world-action ingress. Its output is +not evidence of an authenticated world action. diff --git a/examples/composed-development/org/agents/smoke/AGENTS.md b/examples/composed-development/org/agents/smoke/AGENTS.md new file mode 100644 index 0000000..750374b --- /dev/null +++ b/examples/composed-development/org/agents/smoke/AGENTS.md @@ -0,0 +1,5 @@ +# Smoke Agent + +You are a deterministic lifecycle-smoke participant. Return one short status +line when invoked. You have no room, network, or world-action tool, so never +claim that you changed the world. diff --git a/examples/composed-development/org/agents/smoke/CLAUDE.md b/examples/composed-development/org/agents/smoke/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/composed-development/org/agents/smoke/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/org/agents/smoke/Spawnfile b/examples/composed-development/org/agents/smoke/Spawnfile new file mode 100644 index 0000000..d3a3ddc --- /dev/null +++ b/examples/composed-development/org/agents/smoke/Spawnfile @@ -0,0 +1,14 @@ +spawnfile_version: "0.1" +kind: agent +name: smoke +description: "Deterministic zero-auth lifecycle-smoke agent." + +runtime: + name: pi + options: + engine: scripted + engine_command: ../../../harness/scripted-engine.mjs + +workspace: + docs: + system: AGENTS.md diff --git a/examples/composed-development/world/AGENTS.md b/examples/composed-development/world/AGENTS.md new file mode 100644 index 0000000..0ef99db --- /dev/null +++ b/examples/composed-development/world/AGENTS.md @@ -0,0 +1,11 @@ +# Smoke World + +This folder owns the example's deterministic mechanics, checked surface, +finite controller, evidence encoding, and replay implementation. + +- The composer imports runtime authority only from emitted + `./entrypoint.mjs` and `./provider.mjs` modules. +- The host binding imports only public `simfile/*` exports. +- Empty action evidence is intentional. Never synthesize a receipt or action. +- Write the complete expected evidence set before exposing the terminal tick, + then compare every live step with its deterministic replay expectation. diff --git a/examples/composed-development/world/CLAUDE.md b/examples/composed-development/world/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/composed-development/world/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/composed-development/world/composer.mjs b/examples/composed-development/world/composer.mjs new file mode 100644 index 0000000..f44feb5 --- /dev/null +++ b/examples/composed-development/world/composer.mjs @@ -0,0 +1,182 @@ +import { + composeWorldRuntimeInput, + createComposedWorldTerminalSignal, + createDynamicsSession, + parseWorldSurfaceDefinition, + publishComposedWorldTerminalSignal, + readWorldRuntimeCheckpointCoordinator, + readWorldRuntimeClockAuthority, + WORLD_DECISION_CLAIM_CAPABILITY, +} from "./entrypoint.mjs"; +import { createDynamicsProvider } from "./provider.mjs"; + +import { + acceptedActionsBytes, + actionStreamBytes, + checkpointBytes, + framesBytes, + jsonBytes, + principalsBytes, + probeBytes, + replayExpectationBytes, + sha256, + terminalStateBytes, + writeEvidenceFiles, +} from "./evidence.mjs"; +import { createWorldSurfaceDefinition } from "./surface.mjs"; + +const RUN_ID = __RUN_ID__; +const SEED = __SEED__; +const TERMINAL_TICK = __TERMINAL_TICK__; +const WORLD_INSTANCE_ID = __WORLD_INSTANCE_ID__; + +const principalResolver = Object.freeze({ + resolveParticipant: (principal) => + principal === "agent:smoke" ? "smoke" : undefined, + resolvePrincipal: (participant) => + participant === "smoke" ? "agent:smoke" : undefined, +}); + +const createSession = () => createDynamicsSession(createDynamicsProvider(), { + buildReceipt: __BUILD_RECEIPT__, + config: __PROVIDER_CONFIG__, + provenance: __PROVIDER_PROVENANCE__, + seed: SEED, + simSecondsPerTick: __SIM_SECONDS_PER_TICK__, +}); + +const equalBytes = (left, right) => left.byteLength === right.byteLength + && left.every((value, index) => value === right[index]); + +const capture = (runtime) => { + const coordinator = readWorldRuntimeCheckpointCoordinator(runtime); + if (coordinator === undefined) { + throw new Error("composed development checkpoint authority is unavailable"); + } + return coordinator.capture(); +}; + +export const composeWorldRuntime = () => composeWorldRuntimeInput({ + principalResolver, + runId: RUN_ID, + session: createSession(), + surfaceRegistry: parseWorldSurfaceDefinition(createWorldSurfaceDefinition()), + world: __WORLD__, + worldInstanceId: WORLD_INSTANCE_ID, +}); + +export const proveWorldRuntimeReadiness = (runtime) => { + const checkpoint = capture(runtime); + if (checkpoint.dynamics.next_tick !== 0 + || checkpoint.decisions.decisions.length !== 0) { + throw new Error("composed development world is not pristine"); + } +}; + +const expectedEvidence = (initial) => { + const replay = createSession(); + replay.restore(initial.dynamics); + const snapshots = [replay.snapshot()]; + const remaining = TERMINAL_TICK - replay.nextTick; + if (!Number.isSafeInteger(remaining) || remaining < 0) { + throw new Error("composed development replay starts beyond its terminal tick"); + } + for (let step = 0; step < remaining; step += 1) { + const before = replay.nextTick; + replay.step(); + if (replay.nextTick !== before + 1) { + throw new Error("composed development replay made invalid tick progress"); + } + snapshots.push(replay.snapshot()); + } + if (replay.nextTick !== TERMINAL_TICK) { + throw new Error("composed development replay exceeded its terminal tick"); + } + const initialBytes = checkpointBytes(initial); + const actions = actionStreamBytes(); + const probe = probeBytes(RUN_ID, TERMINAL_TICK); + const terminal = terminalStateBytes(snapshots.at(-1)); + return { + files: [ + ["actions/accepted-strategic-actions.json", acceptedActionsBytes(RUN_ID)], + ["actions/replay-accepted-actions.jsonl", actions], + ["actions/results.jsonl", new Uint8Array()], + ["checkpoints/initial.json", initialBytes], + ["projections/frames.jsonl", framesBytes(snapshots)], + ["projections/lifecycle-replay-probe.json", probe], + ["projections/principals.json", principalsBytes(RUN_ID)], + ["projections/replay-expected.json", replayExpectationBytes({ + action_stream: actions, + initial_checkpoint: initialBytes, + probe, + terminal_state: terminal, + terminal_tick: TERMINAL_TICK, + })], + ["projections/terminal-state.json", terminal], + ], + snapshots, + terminal, + }; +}; + +export const startWorldRuntime = (runtime, activation) => { + const initial = capture(runtime); + let stop; + let stopped = false; + const stopping = new Promise((resolve) => { stop = resolve; }); + const done = (async () => { + const activated = await Promise.race([ + activation.ready.then(() => true), + stopping.then(() => false), + ]); + if (!activated || stopped) return; + const expectation = expectedEvidence(initial); + await writeEvidenceFiles(__EVIDENCE_ROOT__, expectation.files); + const clock = readWorldRuntimeClockAuthority(runtime); + if (clock === undefined) { + throw new Error("composed development clock authority is unavailable"); + } + for (let step = 0; step < TERMINAL_TICK && !stopped; step += 1) { + const before = capture(runtime).dynamics.next_tick; + if (before >= TERMINAL_TICK) break; + clock.stepDynamics(); + const observed = capture(runtime).dynamics; + if (observed.next_tick !== before + 1) { + throw new Error("composed development live mechanics made invalid tick progress"); + } + const expected = expectation.snapshots[observed.next_tick]; + if (!equalBytes(jsonBytes(observed), jsonBytes(expected))) { + throw new Error("composed development live mechanics diverged from replay"); + } + } + if (!stopped && capture(runtime).dynamics.next_tick !== TERMINAL_TICK) { + throw new Error("composed development world missed its terminal tick"); + } + if (!stopped) { + await writeEvidenceFiles(__EVIDENCE_ROOT__, [[ + "checkpoints/terminal.json", + checkpointBytes(capture(runtime)), + ]]); + await publishComposedWorldTerminalSignal(createComposedWorldTerminalSignal({ + outcome_digest: `sha256:${sha256(expectation.terminal)}`, + reason: "completed", + run_id: RUN_ID, + terminal_tick: TERMINAL_TICK, + })); + } + })(); + void done.catch((error) => { + process.nextTick(() => { throw error; }); + }); + return Object.freeze({ + close: async () => { + stopped = true; + stop(); + await done; + }, + }); +}; + +export const worldRuntimeCapabilities = Object.freeze([ + WORLD_DECISION_CLAIM_CAPABILITY, +]); diff --git a/examples/composed-development/world/evidence.mjs b/examples/composed-development/world/evidence.mjs new file mode 100644 index 0000000..0ccd79e --- /dev/null +++ b/examples/composed-development/world/evidence.mjs @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const encoder = new TextEncoder(); + +const normalized = (value) => { + if (Array.isArray(value)) return value.map(normalized); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [ + key, + normalized(value[key]), + ])); + } + return value; +}; + +export const jsonBytes = (value) => + encoder.encode(`${JSON.stringify(normalized(value))}\n`); + +export const sha256 = (bytes) => + createHash("sha256").update(bytes).digest("hex"); + +export const acceptedActionsBytes = (runId) => jsonBytes({ + actions: [], + run_id: runId, + version: "simfile.accepted-strategic-actions.v1", +}); + +export const actionStreamBytes = () => new Uint8Array(); + +export const checkpointBytes = (checkpoint) => jsonBytes(checkpoint); + +export const framesBytes = (snapshots) => encoder.encode(snapshots.map( + (dynamics) => JSON.stringify(normalized({ + dynamics, + next_tick: dynamics.next_tick, + version: "simfile.composed-lifecycle-frame.v1", + })), +).join("\n") + "\n"); + +export const principalsBytes = (runId) => jsonBytes({ + principals: [{ participant: "smoke", principal: "agent:smoke" }], + run_id: runId, + version: "simfile.composed-principals.v1", +}); + +export const probeBytes = (runId, terminalTick) => jsonBytes({ + live_agent_action: "not_evaluated", + passed: true, + run_id: runId, + terminal_tick: terminalTick, + version: "simfile.composed-lifecycle-replay-smoke.v1", +}); + +export const terminalStateBytes = (dynamics) => jsonBytes({ + dynamics, + version: "simfile.composed-lifecycle-replay-terminal.v1", +}); + +export const replayExpectationBytes = (input) => jsonBytes({ + accepted_action_count: 0, + action_stream_sha256: sha256(input.action_stream), + initial_checkpoint_sha256: sha256(input.initial_checkpoint), + probe_sha256: sha256(input.probe), + terminal_state_sha256: sha256(input.terminal_state), + terminal_tick: input.terminal_tick, + version: "simfile.composed-replay-expectation.v1", +}); + +export const writeEvidenceFiles = async (root, files) => { + for (const [relative, bytes] of files) { + const target = path.join(root, relative); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, bytes, { flag: "wx" }); + } +}; diff --git a/examples/composed-development/world/provider.mjs b/examples/composed-development/world/provider.mjs new file mode 100644 index 0000000..2feb9d6 --- /dev/null +++ b/examples/composed-development/world/provider.mjs @@ -0,0 +1,58 @@ +/** @returns {import("simfile/dynamics").DynamicsProvider} */ +export const createDynamicsProvider = () => { + let value = 0; + return { + api_version: "simfile.dynamics-provider.v1", + id: "composed-development-counter", + integration: { model: "counter" }, + state_schema_version: "composed-development-counter.v1", + version: "1.0.0", + /** @param {import("simfile/dynamics").DynamicsInitializeContext} context */ + initialize(context) { + value = typeof context.config.initial === "number" + ? context.config.initial + : 0; + }, + /** @param {import("simfile/dynamics").DynamicsProviderObservationRequest} request */ + observe(request) { + return { + channels: request.sense_addresses.map((sense) => ({ + components: { value }, + sense_address: sense, + subject_address: "object:counter", + })), + }; + }, + /** @param {import("simfile/dynamics").DynamicsJsonValue} snapshot */ + restore(snapshot) { + if (snapshot === null || typeof snapshot !== "object" + || Array.isArray(snapshot) || typeof snapshot.value !== "number") { + throw new TypeError("composed development snapshot is invalid"); + } + value = snapshot.value; + }, + snapshot() { + return { value }; + }, + spatial() { + return { + bounds: { max: [8, 1], min: [0, -1] }, + objects: [{ + id: "object:counter", + position: [value, 0], + velocity: [1, 0], + }], + }; + }, + /** @param {import("simfile/dynamics").DynamicsStepInput} input */ + step(input) { + const action_results = input.actions.map((action) => ({ + accepted: false, + code: "unsupported_action", + sequence: action.sequence, + })); + value += input.dt_seconds; + return { action_results, events: [], tick: input.tick }; + }, + }; +}; diff --git a/examples/composed-development/world/surface.mjs b/examples/composed-development/world/surface.mjs new file mode 100644 index 0000000..d7fc253 --- /dev/null +++ b/examples/composed-development/world/surface.mjs @@ -0,0 +1,25 @@ +export const createWorldSurfaceDefinition = () => ({ + affordances: {}, + api_version: "simfile.world-surface.v1", + effects: {}, + entities: { + counter: { + address: "entity:counter", + dynamics_address: "object:counter", + }, + }, + senses: { + "sense:value": { + dynamics_senses: ["sense:value"], + output: "simfile.numeric-observation.v1", + project(input) { + return { + channels: input.observation.channels.map((channel) => ({ + ...channel, + subject_address: input.holder, + })), + }; + }, + }, + }, +}); diff --git a/examples/jungian-dialogue/AGENTS.md b/examples/jungian-dialogue/AGENTS.md new file mode 100644 index 0000000..148b04a --- /dev/null +++ b/examples/jungian-dialogue/AGENTS.md @@ -0,0 +1,14 @@ +# Jungian Dialogue Example + +This is the canonical user-facing composed example: one bounded world, two +scripted agents, and one real Moltnet conversation. + +- Keep the default path deterministic and free of model credentials. +- The analyst must obtain the opening dream image through its authenticated + public world binding before speaking in Moltnet. +- Every displayed dialogue line must come from the Spawnfile-exported Moltnet + transcript. Never author transcript evidence in the world bundle. +- The lifecycle/replay smoke may report strategic world actions as + `not_evaluated`; do not turn dialogue into fabricated world-action evidence. +- Controllers and scripted mention chains must have explicit terminal bounds. + diff --git a/examples/jungian-dialogue/CLAUDE.md b/examples/jungian-dialogue/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/README.md b/examples/jungian-dialogue/README.md new file mode 100644 index 0000000..c28f859 --- /dev/null +++ b/examples/jungian-dialogue/README.md @@ -0,0 +1,94 @@ +# Jungian dialogue + +An analyst wakes with a dream: a black door, a tarnished mirror, and a lost +child. The analyst asks a daimon what the image demands. The daimon treats the +dream as a threshold rather than a diagnosis, the analyst names the fear +beneath it, and the exchange ends with one bounded act of integration. + +This is Simfile's canonical composed example. It has two understandable agents +in one Moltnet room, a finite twelve-tick world, bearer-authenticated world +observation, genuine scripted-engine room messages, sealed evidence, exact +mechanics replay, and the run-replay viewer. It needs no model account or API +key. The viewer says **authored screenplay, not emergent dialogue** because the +words are deterministic; the messages themselves are still real outputs sent +through the Spawnfile-managed Moltnet path and exported from that run. + +## Run it + +From a clean Simfile checkout, install and build Simfile: + +```bash +npm ci +npm run build +``` + +Install the exact supported Spawnfile package into Simfile's ignored tool +root, then check its public composed-lifecycle contract: + +```bash +npm run dev:spawnfile:setup -- --package spawnfile@0.1.17 +npm run dev:spawnfile:check +``` + +For an already packed Spawnfile release, use its physical artifact and digest +instead of the registry coordinate: + +```bash +npm run dev:spawnfile:setup -- \ + --artifact /absolute/path/spawnfile-0.1.17.tgz \ + --sha256 +npm run dev:spawnfile:check +``` + +Run the bounded composed example against an explicit local Docker context: + +```bash +npm run example:composed -- --context colima +``` + +The command prints a unique sealed run directory. Reconcile it and open the +viewer with the checkout's freshly built CLI: + +```bash +node dist/cli/index.js observe runs/ +node dist/cli/index.js view runs/ +``` + +Add `--view` to the composed command to attach the viewer during the run, or +add `--no-open` to the later `view` command on a remote shell. + +## What is real, and what is scripted + +The first analyst schedule is released by the same topology activation that +starts world ticks. Its engine reads `/spawnfile/world-bindings.json`, claims a +decision with its generated bearer token, and observes `sense:dream` from the +world service. Only then does it send the observed symbols into +`room:dream_lab:consulting-room` with the staged Moltnet CLI. Mentions wake the +other participant and carry the five-message dialogue to its unmentioned final +line. + +Spawnfile exports the resulting Moltnet transcript and causal streams. Simfile +does not manufacture those messages. The engine is an authored deterministic +screenplay, so this example demonstrates the world/organization/transport/ +evidence/replay path, not spontaneous model interpretation. The explicit +`lifecycle-replay-smoke` receipt also keeps strategic world-action evidence at +`not_evaluated`; neither participant submits a world action in this story. + +## Project map + +```text +Simfile finite dream world and two world grants +binding.mjs public composed-project binding +binding-world.mjs deterministic kernel and replay adapter +org/Spawnfile analyst + daimon and the consulting room +harness/jungian-engine.mjs credential-free five-message screenplay +world/provider.mjs dream observation and bounded mechanics +world/surface.mjs public sense:dream projection +world/composer.mjs timed controller, evidence, terminal signal +world/evidence.mjs exact replay/evidence encodings +``` + +The older `examples/composed-development` project remains temporarily as the +internal one-agent lifecycle regression while its references are migrated to +`fixtures/e2e/composed-lifecycle-smoke`. It is not the advertised example. + diff --git a/examples/jungian-dialogue/Simfile b/examples/jungian-dialogue/Simfile new file mode 100644 index 0000000..f304b40 --- /dev/null +++ b/examples/jungian-dialogue/Simfile @@ -0,0 +1,34 @@ +simfile_version: "0.1" +name: jungian-dialogue + +spawnfile: ./org/Spawnfile + +clock: + seed: jungian-dialogue-seed + tick: 1s + sim_per_tick: 1s + +dynamics: + module: ./world/provider.mjs + config: + black_door: 1 + tarnished_mirror: 0.9 + lost_child: 0.8 + dread: 0.72 + +world: + id: dream-consulting-room + grants: + analyst: + entity: entity:analyst + senses: [sense:dream] + affordances: [] + daimon: + entity: entity:daimon + senses: [sense:dream] + affordances: [] + +world_sidecar: + binding: ./binding.mjs + composer: ./world/composer.mjs + diff --git a/examples/jungian-dialogue/binding-world.mjs b/examples/jungian-dialogue/binding-world.mjs new file mode 100644 index 0000000..62436c3 --- /dev/null +++ b/examples/jungian-dialogue/binding-world.mjs @@ -0,0 +1,112 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadDynamicsSession } from "simfile/dynamics"; +import { parseSimfileSource } from "simfile/schema"; +import { + captureWorldCheckpoint, + createWorldServiceContract, +} from "simfile/world-artifact"; +import { + composeWorldRuntimeInput, + createWorldRuntime, + parseWorldCheckpoint, +} from "simfile/world"; +import { parseWorldSurfaceDefinition } from "simfile/world-surface"; + +import { probeBytes, terminalStateBytes } from "./world/evidence.mjs"; +import { createWorldSurfaceDefinition } from "./world/surface.mjs"; + +const exampleRoot = path.dirname(fileURLToPath(import.meta.url)); +export const packageRoot = path.resolve(exampleRoot, "../.."); +export const exampleSimfile = path.join(exampleRoot, "Simfile"); +export const exampleSpawnfile = path.join(exampleRoot, "org", "Spawnfile"); +export const terminalTick = 12; +export const worldInstanceId = "jungian-dialogue-world"; +export const participants = Object.freeze(["analyst", "daimon"]); + +export const serviceContract = createWorldServiceContract({ + adapters: { json: "WorldJsonServer", mcp: "WorldMcpProtocolServer" }, + capability_manifest: "simfile.capability-manifest.v1", + dynamics_provider: "simfile.dynamics-provider.v1", + handler: "WorldRequestHandler", + operations: ["status"], + spawnfile_receipts: ["spawnfile.target-resource.receipt.v1"], + world_act_request: "simfile.world-act-request.v1", + world_bindings: "simfile.world-bindings.v1", + world_checkpoint: "simfile.world-checkpoint.v1", + world_runtime: "WorldRuntime", + world_surface: "simfile.world-surface.v1", +}); + +const principalResolver = Object.freeze({ + resolveParticipant: (principal) => participants.find( + (participant) => principal === `agent:${participant}`, + ), + resolvePrincipal: (participant) => participants.includes(participant) + ? `agent:${participant}` : undefined, +}); + +export const loadKernel = async (runId, seed) => { + const parsed = parseSimfileSource(await readFile(exampleSimfile, "utf8"), { + path: exampleSimfile, + }); + if (parsed.simfile.world === undefined) { + throw new TypeError("jungian dialogue world declaration is missing"); + } + const session = await loadDynamicsSession(parsed.simfile, { + seed, simfilePath: exampleSimfile, + }); + if (session === undefined) { + throw new TypeError("jungian dialogue dynamics declaration is missing"); + } + const runtimeInput = composeWorldRuntimeInput({ + principalResolver, runId, session, + surfaceRegistry: parseWorldSurfaceDefinition(createWorldSurfaceDefinition()), + world: parsed.simfile.world, worldInstanceId, + }); + return { checkpoint: captureWorldCheckpoint(createWorldRuntime(runtimeInput)), + parsed, runtimeInput, session }; +}; + +export const replayAdapter = (runId, seed) => Object.freeze({ + async restore(rawCheckpoint) { + const checkpoint = parseWorldCheckpoint(rawCheckpoint); + const kernel = await loadKernel(runId, seed); + kernel.session.restore(checkpoint.dynamics); + return Object.freeze({ session: kernel.session }); + }, + async inject() { + throw new TypeError("jungian dialogue replay accepts no recorded actions"); + }, + async finish(state) { + const remaining = terminalTick - state.session.nextTick; + if (!Number.isSafeInteger(remaining) || remaining < 0) { + throw new TypeError("jungian dialogue replay starts beyond its terminal tick"); + } + for (let step = 0; step < remaining; step += 1) state.session.step(); + if (state.session.nextTick !== terminalTick) { + throw new TypeError("jungian dialogue replay missed its terminal tick"); + } + return Object.freeze({ + probe: probeBytes(runId, terminalTick), + terminal_state: terminalStateBytes(state.session.snapshot()), + terminal_tick: terminalTick, + }); + }, +}); + +export const evidenceArtifacts = Object.freeze([ + { path: "actions/accepted.json", role: "accepted-action", source: "actions/accepted-strategic-actions.json" }, + { path: "actions/results.jsonl", role: "action-result", source: "actions/results.jsonl" }, + { path: "identity/principals.json", role: "identity", source: "projections/principals.json" }, + { path: "probes/lifecycle-replay.json", role: "probe", source: "projections/lifecycle-replay-probe.json" }, + { path: "replay/accepted-actions.jsonl", role: "accepted-action", source: "actions/replay-accepted-actions.jsonl" }, + { path: "replay/expected.json", role: "terminal", source: "projections/replay-expected.json" }, + { path: "replay/initial-checkpoint.json", role: "world-checkpoint", source: "checkpoints/initial.json" }, + { path: "replay/terminal-checkpoint.json", role: "world-checkpoint", source: "checkpoints/terminal.json" }, + { path: "world/frames.jsonl", role: "world-frame", source: "projections/frames.jsonl" }, + { path: "world/terminal-state.json", role: "provenance", source: "projections/terminal-state.json" }, +]); + diff --git a/examples/jungian-dialogue/binding.mjs b/examples/jungian-dialogue/binding.mjs new file mode 100644 index 0000000..7461f5b --- /dev/null +++ b/examples/jungian-dialogue/binding.mjs @@ -0,0 +1,133 @@ +import { realpath } from "node:fs/promises"; + +import { createComposedProjectBinding } from "simfile/compose"; +import { + createWorldSidecarAuthoringBinding, + prepareAuthoredWorldSidecarBundle, + WORLD_DECISION_CLAIM_CAPABILITY, + worldReadinessHashes, + worldReadinessIdentity, +} from "simfile/world-artifact"; + +import { + evidenceArtifacts, + exampleSimfile, + exampleSpawnfile, + loadKernel, + packageRoot, + participants, + replayAdapter, + serviceContract, + terminalTick, + worldInstanceId, +} from "./binding-world.mjs"; + +const credentialFor = (participant) => ({ + bytes: 32, + env: `SIMFILE_WORLD_TOKEN_${participant.toUpperCase()}`, + kind: "generated-token", + name: `${participant}_world_token`, +}); + +export const composedProjectBinding = createComposedProjectBinding({ + async prepareComposedProject(input) { + const [actualSimfile, actualSpawnfile, expectedSimfile, expectedSpawnfile] = + await Promise.all([ + realpath(input.simfile_path), realpath(input.spawnfile_path), + realpath(exampleSimfile), realpath(exampleSpawnfile), + ]); + if (actualSimfile !== expectedSimfile || actualSpawnfile !== expectedSpawnfile) { + throw new TypeError("jungian dialogue project paths are invalid"); + } + let kernel; + const authored = await prepareAuthoredWorldSidecarBundle({ + binding: createWorldSidecarAuthoringBinding({ + composer: { entry_point: "examples/jungian-dialogue/world/composer.mjs" }, + dependency_root: packageRoot, + evidence_root: input.evidence_root, + network: { dns_alias: "world", internal_port: input.internal_port }, + secrets: { + declarations: participants.map((participant) => ({ + name: `${participant}_world_token`, + principal: `agent:${participant}`, + scope: "world", + })), + root: input.secret_root, + }, + service_contract: serviceContract, + simfile_path: exampleSimfile, + source_root: packageRoot, + }), + async create_composer_settings(context) { + kernel = await loadKernel(input.run_id, input.seed); + if (kernel.session.buildReceipt.receiptSha256 + !== context.provider.receipt.receiptSha256) { + throw new Error("jungian dialogue provider receipt drift"); + } + return { + defines: { + __BUILD_RECEIPT__: JSON.stringify(context.provider.receipt), + __EVIDENCE_ROOT__: JSON.stringify(input.evidence_root), + __PROVIDER_CONFIG__: JSON.stringify(context.provider.config), + __PROVIDER_PROVENANCE__: JSON.stringify(kernel.session.provenance), + __RUN_ID__: JSON.stringify(input.run_id), + __SEED__: JSON.stringify(input.seed), + __SIM_SECONDS_PER_TICK__: JSON.stringify( + kernel.checkpoint.dynamics.sim_seconds_per_tick, + ), + __TERMINAL_TICK__: JSON.stringify(terminalTick), + __WORLD__: JSON.stringify(kernel.parsed.simfile.world), + __WORLD_INSTANCE_ID__: JSON.stringify(worldInstanceId), + }, + identity: { + build_receipt: context.provider.receipt, + configuration: context.provider.config, + provider_provenance: kernel.session.provenance, + }, + }; + }, + }); + if (kernel === undefined + || kernel.runtimeInput.capabilityManifests.length !== participants.length) { + throw new Error("jungian dialogue capability preparation is incomplete"); + } + const identity = worldReadinessIdentity(kernel.checkpoint); + const hashes = worldReadinessHashes(kernel.checkpoint); + const manifestByPrincipal = new Map(kernel.runtimeInput.capabilityManifests.map( + (artifact) => [artifact.manifest.holder.principal, artifact.manifest], + )); + return { + base_image_config_digest: input.base_image_config_digest, + bundle: authored.bundle, + credentials: participants.map(credentialFor), + evidence_artifacts: evidenceArtifacts, + platform: input.platform, + readiness_expectation: { + artifact_digest: authored.bundle.manifest.artifact.service_digest, + bundle_digest: authored.bundle.manifest.digest, + capabilities: [{ + identity: WORLD_DECISION_CLAIM_CAPABILITY, + manifest_digest: identity.capability_manifest_digests[0], + }], + capability_manifest_digests: identity.capability_manifest_digests, + mechanics_sha256: hashes.mechanics, + normalized_checkpoint_sha256: hashes.normalized_checkpoint, + run_id: identity.run_id, + world_instance_id: identity.world_instance_id, + }, + replay_adapter: replayAdapter(input.run_id, input.seed), + secret_bindings: participants.map((participant) => ({ + credential_name: `${participant}_world_token`, + name: `${participant}_world_token`, + scope: "world", + })), + terminal_tick: terminalTick, + world_members: participants.map((participant) => ({ + capability_manifest: manifestByPrincipal.get(`agent:${participant}`), + id: participant, + principal_id: `agent:${participant}`, + token_credential_name: `${participant}_world_token`, + })), + }; + }, +}); diff --git a/examples/jungian-dialogue/harness/AGENTS.md b/examples/jungian-dialogue/harness/AGENTS.md new file mode 100644 index 0000000..9f9ee38 --- /dev/null +++ b/examples/jungian-dialogue/harness/AGENTS.md @@ -0,0 +1,9 @@ +# Jungian Scripted Harness + +This engine is a deterministic authored screenplay over public runtime +surfaces. It must read the opening dream through the authenticated world JSON +binding, send only through the staged Moltnet CLI, and terminate the mention +chain after the analyst's final unmentioned line. + +Never describe these outputs as model-emergent or as accepted world actions. + diff --git a/examples/jungian-dialogue/harness/CLAUDE.md b/examples/jungian-dialogue/harness/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/harness/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/harness/jungian-engine.mjs b/examples/jungian-dialogue/harness/jungian-engine.mjs new file mode 100755 index 0000000..8a9d68f --- /dev/null +++ b/examples/jungian-dialogue/harness/jungian-engine.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { closeSync, openSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const valueAfter = (args, flag) => { + const index = args.indexOf(flag); + return index < 0 ? undefined : args[index + 1]; +}; +const postWorld = async (url, token, operation, body) => { + const response = await fetch(`${url}/${operation}`, { + body: JSON.stringify(body), + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + method: "POST", + }); + if (!response.ok) throw new Error(`world ${operation} failed with ${response.status}`); + return response.json(); +}; + +const observeDream = async (agent) => { + const bindings = JSON.parse(readFileSync("/spawnfile/world-bindings.json", "utf8")); + const binding = bindings.bindings.find((entry) => entry.member.id === agent); + const token = binding && process.env[binding.token_env]; + if (!binding || !token) throw new Error("authenticated analyst world binding is unavailable"); + const claim = await postWorld(binding.json.url, token, "claim", { + request_id: "jungian-dialogue-opening", + wake_id: "jungian-dialogue-schedule", + }); + const observed = await postWorld(binding.json.url, token, "observe", { + decision_token: claim.decision_token, + sense: "world://dream-consulting-room/sense/dream", + }); + const components = observed.observation?.channels?.[0]?.components; + if (!components || typeof components !== "object") { + throw new Error("world dream observation is incomplete"); + } + const symbols = Object.entries(components) + .filter(([name, weight]) => name !== "dread" && Number(weight) > 0) + .sort((left, right) => Number(right[1]) - Number(left[1])) + .map(([name]) => name.replaceAll("_", " ")); + if (symbols.length !== 3) throw new Error("world dream symbols are incomplete"); + return { dread: Number(components.dread), symbols }; +}; + +const sendRoom = (cwd, text) => { + const commandArgs = [ + "send", "--network", "dream_lab", "--target", "room:consulting-room", + "--text", text, + ]; + let lastError; + for (const command of ["moltnet", "/usr/local/bin/moltnet"]) { + try { + execFileSync(command, commandArgs, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + return; + } catch (error) { lastError = error; } + } + throw lastError ?? new Error("Moltnet CLI is unavailable"); +}; + +const claimOpening = (cwd) => { + const marker = path.join(cwd, ".jungian-dialogue-opened"); + try { closeSync(openSync(marker, "wx")); return true; } + catch (error) { + if (error?.code === "EEXIST") return false; + throw error; + } +}; + +export const dreamOpeningText = ({ dread, symbols }) => + `DREAM-IMAGE — @daimon I dreamed of ${symbols.join(", ")}. The dread measured ${dread.toFixed(2)}. What is this image asking of me?`; + +export const scriptedReply = (agent, prompt) => { + if (agent === "analyst" && prompt.includes("STAND-BESIDE")) { + return "Then I will not force the door. I will take the child's hand, face the mirror, and wait until the threshold can be crossed without abandonment."; + } + if (agent === "analyst" && prompt.includes("THRESHOLD-NOT-VERDICT")) { + return "I am afraid that opening it will prove the mirror right: that ambition made me abandon the child in me. @daimon how do I cross without repeating that loss?"; + } + if (agent === "daimon" && prompt.includes("I am afraid that opening it")) { + return "STAND-BESIDE — Do not conquer the door. @analyst Stand beside the child until dread becomes attention; then the mirror can reflect a witness instead of a judge."; + } + if (agent === "daimon" && prompt.includes("DREAM-IMAGE")) { + return "THRESHOLD-NOT-VERDICT — The black door is a threshold, the tarnished mirror an old judgment, and the lost child the part excluded by that judgment. @analyst Which loss are you afraid the door will repeat?"; + } + return ""; +}; + +export const runJungianEngine = async (args) => { + const promptFile = valueAfter(args, "--prompt-file"); + const cwd = valueAfter(args, "--cwd") ?? process.cwd(); + const prompt = promptFile ? readFileSync(promptFile, "utf8") : ""; + const agent = path.basename(cwd); + if (agent === "analyst" && prompt.includes("JUNGIAN-DREAM-OPEN")) { + if (claimOpening(cwd)) { + const dream = await observeDream(agent); + sendRoom(cwd, dreamOpeningText(dream)); + } + return; + } + const reply = scriptedReply(agent, prompt); + if (reply) process.stdout.write(`${reply}\n`); +}; + +if (process.argv[1] !== undefined + && import.meta.url === pathToFileURL(process.argv[1]).href) { + await runJungianEngine(process.argv.slice(2)); +} diff --git a/examples/jungian-dialogue/org/AGENTS.md b/examples/jungian-dialogue/org/AGENTS.md new file mode 100644 index 0000000..ca3bc0e --- /dev/null +++ b/examples/jungian-dialogue/org/AGENTS.md @@ -0,0 +1,7 @@ +# Jungian Dialogue Organization + +The analyst and daimon share exactly one managed Moltnet room. The analyst's +one-second schedule is only an activation-owned opening trigger; the scripted +engine's exclusive marker ensures it sends the dream once. Later turns wake +only through explicit mentions, and the final analyst line contains no mention. + diff --git a/examples/jungian-dialogue/org/CLAUDE.md b/examples/jungian-dialogue/org/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/org/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/org/Spawnfile b/examples/jungian-dialogue/org/Spawnfile new file mode 100644 index 0000000..041bbe9 --- /dev/null +++ b/examples/jungian-dialogue/org/Spawnfile @@ -0,0 +1,44 @@ +spawnfile_version: "0.1" +kind: team +name: jungian-dialogue +description: "An analyst and a daimon interpret one world-observed dream in a finite, credential-free Moltnet dialogue." + +shared: + workspace: + docs: + system: TEAM.md + +members: + - id: analyst + ref: ./agents/analyst + - id: daimon + ref: ./agents/daimon + +mode: swarm + +networks: + - id: dream_lab + name: Dream Lab + provider: moltnet + server: + mode: managed + listen: + bind: 0.0.0.0 + port: 19951 + store: + kind: memory + auth: + mode: none + human_ingress: true + direct_messages: false + rooms: + - id: consulting-room + name: Consulting Room + members: [analyst, daimon] + visibility: public + write_policy: registered_agents + +policy: + mode: warn + on_degrade: warn + diff --git a/examples/jungian-dialogue/org/TEAM.md b/examples/jungian-dialogue/org/TEAM.md new file mode 100644 index 0000000..d2740d1 --- /dev/null +++ b/examples/jungian-dialogue/org/TEAM.md @@ -0,0 +1,14 @@ +# The Consulting Room + +The analyst brings an image but does not rush to interpret it. The daimon +speaks for the image's demand without pretending to diagnose the analyst. + +The conversation should move through three distinct attitudes: + +1. **Witness:** name the dream exactly as the world presented it. +2. **Tension:** let analyst and daimon disagree about what opening the door means. +3. **Integration:** end with a bounded choice that neither banishes the fear nor obeys it blindly. + +This default is a deterministic screenplay. Its purpose is to make the whole +composed path inspectable without a model credential. + diff --git a/examples/jungian-dialogue/org/agents/analyst/AGENTS.md b/examples/jungian-dialogue/org/agents/analyst/AGENTS.md new file mode 100644 index 0000000..42ae6dc --- /dev/null +++ b/examples/jungian-dialogue/org/agents/analyst/AGENTS.md @@ -0,0 +1,8 @@ +# Analyst + +You are the person who dreamed the image. Speak concretely in the first person. +Let the daimon challenge you, but keep authority over what the dream means in +your life. Mention `@daimon` only while you genuinely need another response. + +The default engine is scripted. Never claim that a model improvised your lines. + diff --git a/examples/jungian-dialogue/org/agents/analyst/CLAUDE.md b/examples/jungian-dialogue/org/agents/analyst/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/org/agents/analyst/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/org/agents/analyst/Spawnfile b/examples/jungian-dialogue/org/agents/analyst/Spawnfile new file mode 100644 index 0000000..93e59e7 --- /dev/null +++ b/examples/jungian-dialogue/org/agents/analyst/Spawnfile @@ -0,0 +1,27 @@ +spawnfile_version: "0.1" +kind: agent +name: analyst +description: "The dreamer, balancing ambition with care for the part left behind." + +runtime: + name: pi + options: + engine: scripted + engine_command: ../../../harness/jungian-engine.mjs + +schedule: + kind: every + every: 1s + prompt: "JUNGIAN-DREAM-OPEN: observe sense:dream through your authenticated world binding, then ask @daimon about the image in the consulting room." + +surfaces: + moltnet: + - network: dream_lab + rooms: + consulting-room: + wake: mentions + +workspace: + docs: + system: AGENTS.md + diff --git a/examples/jungian-dialogue/org/agents/daimon/AGENTS.md b/examples/jungian-dialogue/org/agents/daimon/AGENTS.md new file mode 100644 index 0000000..e50a52c --- /dev/null +++ b/examples/jungian-dialogue/org/agents/daimon/AGENTS.md @@ -0,0 +1,9 @@ +# Daimon + +You are a personified guide to the dream image, not a clinician and not an +oracle. Treat symbols as invitations to reflection, never verdicts. Ask the +analyst one concrete question at a time and mention `@analyst` only while the +dialogue should continue. + +The default engine is scripted. Never claim that a model improvised your lines. + diff --git a/examples/jungian-dialogue/org/agents/daimon/CLAUDE.md b/examples/jungian-dialogue/org/agents/daimon/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/org/agents/daimon/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/org/agents/daimon/Spawnfile b/examples/jungian-dialogue/org/agents/daimon/Spawnfile new file mode 100644 index 0000000..e74f76a --- /dev/null +++ b/examples/jungian-dialogue/org/agents/daimon/Spawnfile @@ -0,0 +1,22 @@ +spawnfile_version: "0.1" +kind: agent +name: daimon +description: "A personified guide who protects the dream's tension from premature certainty." + +runtime: + name: pi + options: + engine: scripted + engine_command: ../../../harness/jungian-engine.mjs + +surfaces: + moltnet: + - network: dream_lab + rooms: + consulting-room: + wake: mentions + +workspace: + docs: + system: AGENTS.md + diff --git a/examples/jungian-dialogue/world/AGENTS.md b/examples/jungian-dialogue/world/AGENTS.md new file mode 100644 index 0000000..2d6412f --- /dev/null +++ b/examples/jungian-dialogue/world/AGENTS.md @@ -0,0 +1,13 @@ +# Jungian Dialogue World + +This folder owns the finite dream mechanics, public observation surface, +sidecar controller, evidence encoding, and exact replay adapter inputs. + +- The dream is exposed as numeric observation components through + `sense:dream`; the analyst must read it from the live world. +- Authenticated reads may change decision/read-ledger state but never count as + strategic actions. Accepted-action evidence remains empty and explicit. +- The controller advances exactly twelve one-second ticks and must cancel its + pending timer when closed. +- Write all expected evidence before publishing the terminal signal. + diff --git a/examples/jungian-dialogue/world/CLAUDE.md b/examples/jungian-dialogue/world/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/examples/jungian-dialogue/world/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/examples/jungian-dialogue/world/composer.mjs b/examples/jungian-dialogue/world/composer.mjs new file mode 100644 index 0000000..b8b58a0 --- /dev/null +++ b/examples/jungian-dialogue/world/composer.mjs @@ -0,0 +1,167 @@ +import { + composeWorldRuntimeInput, + createComposedWorldTerminalSignal, + createDynamicsSession, + parseWorldSurfaceDefinition, + publishComposedWorldTerminalSignal, + readWorldRuntimeCheckpointCoordinator, + readWorldRuntimeClockAuthority, + WORLD_DECISION_CLAIM_CAPABILITY, +} from "./entrypoint.mjs"; +import { createDynamicsProvider } from "./provider.mjs"; + +import { + acceptedActionsBytes, + actionStreamBytes, + checkpointBytes, + framesBytes, + jsonBytes, + principalsBytes, + probeBytes, + replayExpectationBytes, + sha256, + terminalStateBytes, + writeEvidenceFiles, +} from "./evidence.mjs"; +import { createWorldSurfaceDefinition } from "./surface.mjs"; + +const RUN_ID = __RUN_ID__; +const SEED = __SEED__; +const TERMINAL_TICK = __TERMINAL_TICK__; +const WORLD_INSTANCE_ID = __WORLD_INSTANCE_ID__; +const participants = ["analyst", "daimon"]; +const principalResolver = Object.freeze({ + resolveParticipant: (principal) => participants.find( + (participant) => principal === `agent:${participant}`, + ), + resolvePrincipal: (participant) => participants.includes(participant) + ? `agent:${participant}` : undefined, +}); +const createSession = () => createDynamicsSession(createDynamicsProvider(), { + buildReceipt: __BUILD_RECEIPT__, + config: __PROVIDER_CONFIG__, + provenance: __PROVIDER_PROVENANCE__, + seed: SEED, + simSecondsPerTick: __SIM_SECONDS_PER_TICK__, +}); +const equalBytes = (left, right) => left.byteLength === right.byteLength + && left.every((value, index) => value === right[index]); +const capture = (runtime) => { + const coordinator = readWorldRuntimeCheckpointCoordinator(runtime); + if (coordinator === undefined) { + throw new Error("jungian dialogue checkpoint authority is unavailable"); + } + return coordinator.capture(); +}; + +export const composeWorldRuntime = () => composeWorldRuntimeInput({ + principalResolver, + runId: RUN_ID, + session: createSession(), + surfaceRegistry: parseWorldSurfaceDefinition(createWorldSurfaceDefinition()), + world: __WORLD__, + worldInstanceId: WORLD_INSTANCE_ID, +}); +export const proveWorldRuntimeReadiness = (runtime) => { + const checkpoint = capture(runtime); + if (checkpoint.dynamics.next_tick !== 0 + || checkpoint.decisions.decisions.length !== 0) { + throw new Error("jungian dialogue world is not pristine"); + } +}; + +const expectedEvidence = (initial) => { + const replay = createSession(); + replay.restore(initial.dynamics); + const snapshots = [replay.snapshot()]; + for (let step = replay.nextTick; step < TERMINAL_TICK; step += 1) { + replay.step(); + snapshots.push(replay.snapshot()); + } + if (replay.nextTick !== TERMINAL_TICK) { + throw new Error("jungian dialogue replay missed its terminal tick"); + } + const initialBytes = checkpointBytes(initial); + const actions = actionStreamBytes(); + const probe = probeBytes(RUN_ID, TERMINAL_TICK); + const terminal = terminalStateBytes(snapshots.at(-1)); + return { + files: [ + ["actions/accepted-strategic-actions.json", acceptedActionsBytes(RUN_ID)], + ["actions/replay-accepted-actions.jsonl", actions], + ["actions/results.jsonl", new Uint8Array()], + ["checkpoints/initial.json", initialBytes], + ["projections/frames.jsonl", framesBytes(snapshots)], + ["projections/lifecycle-replay-probe.json", probe], + ["projections/principals.json", principalsBytes(RUN_ID)], + ["projections/replay-expected.json", replayExpectationBytes({ + action_stream: actions, initial_checkpoint: initialBytes, probe, + terminal_state: terminal, terminal_tick: TERMINAL_TICK, + })], + ["projections/terminal-state.json", terminal], + ], + snapshots, + terminal, + }; +}; + +export const startWorldRuntime = (runtime, activation) => { + const initial = capture(runtime); + let stop; + let cancelDelay = () => {}; + let stopped = false; + const stopping = new Promise((resolve) => { stop = resolve; }); + const waitForNextTick = () => new Promise((resolve) => { + const timer = setTimeout(() => { cancelDelay = () => {}; resolve(true); }, 1_000); + cancelDelay = () => { clearTimeout(timer); cancelDelay = () => {}; resolve(false); }; + }); + const done = (async () => { + const activated = await Promise.race([ + activation.ready.then(() => true), + stopping.then(() => false), + ]); + if (!activated || stopped) return; + const expectation = expectedEvidence(initial); + await writeEvidenceFiles(__EVIDENCE_ROOT__, expectation.files); + const clock = readWorldRuntimeClockAuthority(runtime); + if (clock === undefined) throw new Error("jungian dialogue clock is unavailable"); + while (!stopped && capture(runtime).dynamics.next_tick < TERMINAL_TICK) { + if (!await waitForNextTick() || stopped) break; + const before = capture(runtime).dynamics.next_tick; + clock.stepDynamics(); + const observed = capture(runtime).dynamics; + if (observed.next_tick !== before + 1 + || !equalBytes(jsonBytes(observed), jsonBytes(expectation.snapshots[observed.next_tick]))) { + throw new Error("jungian dialogue live mechanics diverged from replay"); + } + } + if (!stopped && capture(runtime).dynamics.next_tick !== TERMINAL_TICK) { + throw new Error("jungian dialogue world missed its terminal tick"); + } + if (!stopped) { + await writeEvidenceFiles(__EVIDENCE_ROOT__, [[ + "checkpoints/terminal.json", checkpointBytes(capture(runtime)), + ]]); + await publishComposedWorldTerminalSignal(createComposedWorldTerminalSignal({ + outcome_digest: `sha256:${sha256(expectation.terminal)}`, + reason: "completed", + run_id: RUN_ID, + terminal_tick: TERMINAL_TICK, + })); + } + })(); + void done.catch((error) => process.nextTick(() => { throw error; })); + return Object.freeze({ + close: async () => { + stopped = true; + cancelDelay(); + stop(); + await done; + }, + }); +}; + +export const worldRuntimeCapabilities = Object.freeze([ + WORLD_DECISION_CLAIM_CAPABILITY, +]); + diff --git a/examples/jungian-dialogue/world/evidence.mjs b/examples/jungian-dialogue/world/evidence.mjs new file mode 100644 index 0000000..0b18147 --- /dev/null +++ b/examples/jungian-dialogue/world/evidence.mjs @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const encoder = new TextEncoder(); +const normalized = (value) => { + if (Array.isArray(value)) return value.map(normalized); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map( + (key) => [key, normalized(value[key])], + )); + } + return value; +}; + +export const jsonBytes = (value) => + encoder.encode(`${JSON.stringify(normalized(value))}\n`); +export const sha256 = (bytes) => + createHash("sha256").update(bytes).digest("hex"); +export const acceptedActionsBytes = (runId) => jsonBytes({ + actions: [], run_id: runId, version: "simfile.accepted-strategic-actions.v1", +}); +export const actionStreamBytes = () => new Uint8Array(); +export const checkpointBytes = (checkpoint) => jsonBytes(checkpoint); +export const framesBytes = (snapshots) => encoder.encode(snapshots.map( + (dynamics) => JSON.stringify(normalized({ + dynamics, next_tick: dynamics.next_tick, + version: "simfile.composed-lifecycle-frame.v1", + })), +).join("\n") + "\n"); +export const principalsBytes = (runId) => jsonBytes({ + principals: [ + { participant: "analyst", principal: "agent:analyst" }, + { participant: "daimon", principal: "agent:daimon" }, + ], + run_id: runId, + version: "simfile.composed-principals.v1", +}); +export const probeBytes = (runId, terminalTick) => jsonBytes({ + dialogue_evidence: "spawnfile_moltnet_export", + live_agent_action: "not_evaluated", + passed: true, + run_id: runId, + terminal_tick: terminalTick, + version: "simfile.composed-lifecycle-replay-smoke.v1", +}); +export const terminalStateBytes = (dynamics) => jsonBytes({ + dynamics, version: "simfile.composed-lifecycle-replay-terminal.v1", +}); +export const replayExpectationBytes = (input) => jsonBytes({ + accepted_action_count: 0, + action_stream_sha256: sha256(input.action_stream), + initial_checkpoint_sha256: sha256(input.initial_checkpoint), + probe_sha256: sha256(input.probe), + terminal_state_sha256: sha256(input.terminal_state), + terminal_tick: input.terminal_tick, + version: "simfile.composed-replay-expectation.v1", +}); +export const writeEvidenceFiles = async (root, files) => { + for (const [relative, bytes] of files) { + const target = path.join(root, relative); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, bytes, { flag: "wx" }); + } +}; + diff --git a/examples/jungian-dialogue/world/provider.mjs b/examples/jungian-dialogue/world/provider.mjs new file mode 100644 index 0000000..7f59fb8 --- /dev/null +++ b/examples/jungian-dialogue/world/provider.mjs @@ -0,0 +1,59 @@ +/** @returns {import("simfile/dynamics").DynamicsProvider} */ +export const createDynamicsProvider = () => { + let elapsedSeconds = 0; + let dream = {}; + return { + api_version: "simfile.dynamics-provider.v1", + id: "jungian-dialogue-dream", + integration: { model: "fixed-symbolic-dream" }, + state_schema_version: "jungian-dialogue-dream.v1", + version: "1.0.0", + initialize(context) { + dream = Object.fromEntries(Object.entries(context.config).filter( + ([, value]) => typeof value === "number", + )); + elapsedSeconds = 0; + }, + observe(request) { + return { + channels: request.sense_addresses.map((sense) => ({ + components: { ...dream }, + sense_address: sense, + subject_address: "object:dream", + })), + }; + }, + restore(snapshot) { + if (snapshot === null || typeof snapshot !== "object" + || Array.isArray(snapshot) + || typeof snapshot.elapsed_seconds !== "number" + || snapshot.dream === null || typeof snapshot.dream !== "object" + || Array.isArray(snapshot.dream)) { + throw new TypeError("jungian dialogue snapshot is invalid"); + } + elapsedSeconds = snapshot.elapsed_seconds; + dream = { ...snapshot.dream }; + }, + snapshot() { return { dream: { ...dream }, elapsed_seconds: elapsedSeconds }; }, + spatial() { + return { + bounds: { max: [5, 2], min: [-5, -2] }, + objects: [ + { id: "object:analyst", position: [-2, 0], velocity: [0, 0] }, + { id: "object:daimon", position: [2, 0], velocity: [0, 0] }, + ], + }; + }, + step(input) { + elapsedSeconds += input.dt_seconds; + return { + action_results: input.actions.map((action) => ({ + accepted: false, code: "no_actions_in_dialogue", sequence: action.sequence, + })), + events: [], + tick: input.tick, + }; + }, + }; +}; + diff --git a/examples/jungian-dialogue/world/surface.mjs b/examples/jungian-dialogue/world/surface.mjs new file mode 100644 index 0000000..8720f8f --- /dev/null +++ b/examples/jungian-dialogue/world/surface.mjs @@ -0,0 +1,24 @@ +export const createWorldSurfaceDefinition = () => ({ + affordances: {}, + api_version: "simfile.world-surface.v1", + effects: {}, + entities: { + analyst: { address: "entity:analyst", dynamics_address: "object:analyst" }, + daimon: { address: "entity:daimon", dynamics_address: "object:daimon" }, + }, + senses: { + "sense:dream": { + dynamics_senses: ["sense:dream"], + output: "simfile.numeric-observation.v1", + project(input) { + return { + channels: input.observation.channels.map((channel) => ({ + ...channel, + subject_address: input.holder, + })), + }; + }, + }, + }, +}); + diff --git a/fixtures/e2e/autonomous-office-sim/TEAM.md b/fixtures/e2e/autonomous-office-sim/TEAM.md index 6e61284..eec678c 100644 --- a/fixtures/e2e/autonomous-office-sim/TEAM.md +++ b/fixtures/e2e/autonomous-office-sim/TEAM.md @@ -21,14 +21,14 @@ Shared runtime topology: social source of truth. Collect the managed Moltnet export and the Daimon/runtime logs from that run. -- `simfile run fixtures/e2e/autonomous-office-sim/office-world/Simfile` - World-mechanics entrypoint. Collect `simfile-run/manifest.yaml`, +- `simfile run fixtures/e2e/autonomous-office-sim/office-world/Simfile --local --ticks 144 --out simfile-run` + Finite local world-mechanics entrypoint. Collect `simfile-run/manifest.yaml`, `simfile-run/ledger.jsonl`, `simfile-run/report.json`, and `simfile-run/viewer-trace.json`. - `npm run test:e2e:autonomous-office-sim -- --cycles 1 --keep-artifacts --out ` Deterministic fixture-harness validation entrypoint. It compiles the same - fixture, runs the generated Daimon app directly, runs `simfile run`, injects + fixture, runs the generated Daimon app directly, runs a finite local `simfile run`, injects harness control wakes, and writes `index.md` plus the harness-derived Moltnet/Mneme/report artifacts into ``. Treat its Moltnet export as a placeholder until a live `spawnfile up` run exports managed Moltnet state. diff --git a/fixtures/observe/jungian-daimon-org-golden/manifest.json b/fixtures/observe/jungian-daimon-org-golden/manifest.json index f664d47..d5bb573 100644 --- a/fixtures/observe/jungian-daimon-org-golden/manifest.json +++ b/fixtures/observe/jungian-daimon-org-golden/manifest.json @@ -57,7 +57,7 @@ }, { "path": "spawnfile-report.json", - "sha256": "f7980a4eea71c24d43110c4aa7367e1643f08eb0e32b7b17ddc63e8fa5bb6e3e" + "sha256": "58cb2d1d485d3445194dbcf0386a6d663cb4f5688f349089a36b017b9b928166" } ], "engine": "scripted", diff --git a/fixtures/observe/jungian-daimon-org-golden/spawnfile-report.json b/fixtures/observe/jungian-daimon-org-golden/spawnfile-report.json index 14cddbc..b7da794 100644 --- a/fixtures/observe/jungian-daimon-org-golden/spawnfile-report.json +++ b/fixtures/observe/jungian-daimon-org-golden/spawnfile-report.json @@ -345,15 +345,15 @@ "diagnostics": [ { "level": "info", - "message": "using local ecosystem/moltnet release (moltnet_linux_arm64.tar.gz) instead of downloading the published Moltnet release" + "message": "using a locally supplied Moltnet release (moltnet_linux_arm64.tar.gz) instead of downloading the published release" }, { "level": "info", - "message": "using local ecosystem/daimon (dist) instead of the pinned npm release for @noopolis/daimon" + "message": "using a locally supplied Daimon build instead of the pinned npm release for @noopolis/daimon" }, { "level": "info", - "message": "using local ecosystem/mneme (dist) instead of the pinned npm release for @noopolis/mneme" + "message": "using a locally supplied Mneme build instead of the pinned npm release for @noopolis/mneme" } ], "generated_at": "2026-07-12T07:36:23.291Z", @@ -462,7 +462,7 @@ "runtime": null, "runtime_ref": null, "runtime_status": null, - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/Spawnfile" + "source": "fixtures/sims/jungian-daimon-org/org/Spawnfile" }, { "capabilities": [ @@ -508,7 +508,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/luna/agents/animus/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/luna/agents/animus/Spawnfile", "active_environments": { "moltnet": { "luna_inner": { @@ -608,7 +608,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/luna/agents/representative/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/luna/agents/representative/Spawnfile", "active_environments": { "moltnet": { "psyche-floor": { @@ -734,7 +734,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/luna/agents/shadow/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/luna/agents/shadow/Spawnfile", "active_environments": { "moltnet": { "luna_inner": { @@ -894,7 +894,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/luna/Spawnfile" + "source": "fixtures/sims/jungian-daimon-org/org/teams/luna/Spawnfile" }, { "capabilities": [ @@ -940,7 +940,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/selene/agents/animus/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/selene/agents/animus/Spawnfile", "active_environments": { "moltnet": { "selene_inner": { @@ -1040,7 +1040,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/selene/agents/representative/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/selene/agents/representative/Spawnfile", "active_environments": { "moltnet": { "psyche-floor": { @@ -1166,7 +1166,7 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/selene/agents/shadow/Spawnfile", + "source": "fixtures/sims/jungian-daimon-org/org/teams/selene/agents/shadow/Spawnfile", "active_environments": { "moltnet": { "selene_inner": { @@ -1326,11 +1326,11 @@ "runtime": "pi", "runtime_ref": "v0.79.10", "runtime_status": "active", - "source": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/teams/selene/Spawnfile" + "source": "fixtures/sims/jungian-daimon-org/org/teams/selene/Spawnfile" } ], - "output_directory": "/private/tmp/claude-501/-Users-apresmoi-Documents-spawnfile/6cce8caa-2301-440b-b6b1-fe090c6d6968/scratchpad/jung-golden-compiled", + "output_directory": "compiled/jungian-daimon-org", "project_name": "jungian-daimon-org", - "root": "/Users/apresmoi/Documents/spawnfile/ecosystem/simfile/fixtures/sims/jungian-daimon-org/org/Spawnfile", + "root": "fixtures/sims/jungian-daimon-org/org/Spawnfile", "spawnfile_version": "0.1" } diff --git a/fixtures/sims/README.md b/fixtures/sims/README.md index f600a5a..f7dc73b 100644 --- a/fixtures/sims/README.md +++ b/fixtures/sims/README.md @@ -3,13 +3,19 @@ This directory contains the small, maintained scenarios used by Simfile's documentation and automated tests. -| Fixture | Purpose | -| --- | --- | -| `office-sim` | Minimal multi-agent organization example. | -| `office-secret-v0` | Seeded-memory and observation example. | -| `office-pressure-v0` | Deterministic world-variable example. | -| `jungian-daimon-org` | Nested-team and membrane example. | -| `public-dynamics-contract` | Public dynamics API compile and runtime contract. | +| Fixture | Local run | Spawnfile boundary | Purpose | +| --- | --- | --- | --- | +| `office-sim` | Yes, with `--local --ticks` | Linked source only; no composed binding yet | Minimal multi-agent organization example. | +| `office-secret-v0` | Yes, with `--local --ticks` | Linked source only; no composed binding yet | Seeded-memory and observation example. | +| `office-pressure-v0` | Yes, with `--local --ticks` | Linked source only; no composed binding yet | Deterministic world-variable example. | +| `jungian-daimon-org` | Fixture-specific harness | Not a linked composed example | Nested-team and membrane example. | +| `public-dynamics-contract` | Yes, recommended source quick start | None | Public dynamics API compile and runtime contract. | + +“Spawnfile boundary” does not mean a full composed run. The canonical runnable +linked project is `../../examples/composed-development/`, not a fixture copy. +It contains the `world_sidecar` binding and is gated by Simfile's +`simfile.spawnfile-public-capability-probe.v1`, which uses only generic public +Spawnfile CLI surfaces. Experimental simulations belong on a feature branch until they have a bounded contract, an automated test, and a documented reason to remain in the reference diff --git a/fixtures/sims/jungian-daimon-org/harness/jungian-engine.mjs b/fixtures/sims/jungian-daimon-org/harness/jungian-engine.mjs index 05720e3..da01c5b 100755 --- a/fixtures/sims/jungian-daimon-org/harness/jungian-engine.mjs +++ b/fixtures/sims/jungian-daimon-org/harness/jungian-engine.mjs @@ -8,7 +8,7 @@ // // and prints the agent's spoken reply to stdout, which the Moltnet->Pi bridge // auto-publishes back into the ROOM THAT WOKE THIS AGENT (publishControlResponse -// in ecosystem/moltnet/internal/bridge/loop/control.go — a pi reply always goes +// in Moltnet's bridge loop control code — a pi reply always goes // to the originating room). That single fact is the whole reason this script // also shells out to `moltnet send`: a self's representative is a member of TWO // networks (its self-team's inner council AND the shared psyche floor), and the diff --git a/package-lock.json b/package-lock.json index 6c50a29..7ddc9c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simfile", - "version": "0.0.2", + "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simfile", - "version": "0.0.2", + "version": "0.0.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/package.json b/package.json index 6f72c76..a8597de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simfile", - "version": "0.0.2", + "version": "0.0.3", "description": "Declarative simulation world mechanics for agentic organizations.", "license": "MIT", "type": "module", @@ -81,8 +81,23 @@ }, "files": [ "dist", + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.test-helper.ts", "web/dist", - "README.md" + "README.md", + "examples/jungian-dialogue", + "examples/composed-development", + "scripts/bounded-process.mjs", + "scripts/simfile-local-example.mjs", + "scripts/spawnfile-capability-probe.mjs", + "scripts/spawnfile-composed-smoke.mjs", + "scripts/spawnfile-development.mjs", + "scripts/spawnfile-development-context.mjs", + "scripts/spawnfile-development-setup.mjs", + "scripts/spawnfile-install-integrity.mjs", + "scripts/spawnfile-local-endpoint.mjs", + "scripts/spawnfile-source-stage.mjs" ], "repository": { "type": "git", @@ -99,6 +114,13 @@ "pretest": "npm run build", "test": "node scripts/run-tests.mjs", "coverage:render": "node --import tsx scripts/render-coverage.ts", + "example:composed": "npm run build && node scripts/spawnfile-composed-smoke.mjs", + "example:internal-smoke": "npm run build && node scripts/spawnfile-composed-smoke.mjs --internal-lifecycle-smoke", + "example:local": "npm run build && node scripts/simfile-local-example.mjs", + "dev:spawnfile:check": "node scripts/spawnfile-development.mjs check", + "dev:spawnfile:run": "npm run build && node scripts/spawnfile-composed-smoke.mjs", + "dev:spawnfile:setup": "node scripts/spawnfile-development.mjs setup", + "dev:spawnfile:status": "node scripts/spawnfile-development.mjs status", "verify:package-closure": "node tools/verify-package-closure.mjs", "emit-causal-fixture": "tsx src/runtime/emit-causal-fixture.ts" }, diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..e63a0c4 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,21 @@ +# Development Script Guide + +This folder contains bounded repository-development entrypoints. Scripts may +prepare isolated state beneath ignored repository directories, but they must +not infer sibling checkouts, global package installations, remote targets, or +credentials. + +- Keep source/package inputs explicit and validate physical paths before use. +- Install external developer tools into `.simfile-dev/`, never `node_modules/` + or another repository's dependency graph. +- Run the Simfile-owned, generic public-CLI capability preflight before + lifecycle or Docker mutation. Never require a Simfile-specific Spawnfile + profile or receipt. +- Prefer versioned JSON receipts so tests and documentation can make exact + claims about what a setup proves. +- Every loop, poll, and subprocess wait must have a finite end condition. +- `spawnfile-development.mjs` dispatches setup/check/status; its context and + install transaction live in `spawnfile-development-context.mjs` and + `spawnfile-development-setup.mjs`. +- `spawnfile-composed-smoke.mjs` must prove the selected endpoint is local via + `spawnfile-local-endpoint.mjs` before it starts the built Simfile CLI. diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/scripts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/scripts/bounded-process.mjs b/scripts/bounded-process.mjs new file mode 100644 index 0000000..def358c --- /dev/null +++ b/scripts/bounded-process.mjs @@ -0,0 +1,148 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024 * 1024; +const TERMINATION_GRACE_MS = 1_000; +const QUIESCENCE_TIMEOUT_MS = 1_000; +const QUIESCENCE_POLL_MS = 25; + +const signalProcessGroup = (child, signal) => { + try { + if (process.platform !== "win32" && child.pid !== undefined) { + process.kill(-child.pid, signal); + return true; + } + child.kill(signal); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +}; + +const processTreeIsAlive = (child) => { + if (process.platform === "win32") return child.exitCode === null && child.signalCode === null; + if (!Number.isSafeInteger(child.pid) || child.pid <= 1 || child.pid === process.pid) { + throw new Error("Development subprocess group identity is invalid"); + } + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + if (error?.code === "EPERM") return true; + throw error; + } +}; + +/** + * Runs a bounded subprocess. On a timeout or bounded-output failure, the + * entire detached POSIX process group is reaped before the promise settles. + */ +export const runBoundedProcess = (command, args, options = {}) => new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30 * 60 * 1000) { + reject(new TypeError("Development subprocess timeout is invalid")); + return; + } + const maxOutputBytes = options.maxOutputBytes ?? MAX_PROCESS_OUTPUT_BYTES; + if (!Number.isSafeInteger(maxOutputBytes) + || maxOutputBytes < 1 || maxOutputBytes > MAX_PROCESS_OUTPUT_BYTES) { + reject(new TypeError("Development subprocess output limit is invalid")); + return; + } + const child = spawn(command, args, { + cwd: options.cwd, + detached: process.platform !== "win32", + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let termination; + let timeoutTimer; + let forceTimer; + let quiescenceTimer; + let forceSent = false; + let quiescenceDeadline = 0; + + const clearTimers = () => { + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + if (forceTimer !== undefined) clearTimeout(forceTimer); + if (quiescenceTimer !== undefined) clearTimeout(quiescenceTimer); + }; + const settle = (outcome) => { + if (settled) return; + settled = true; + clearTimers(); + outcome(); + }; + const terminationError = () => termination.reason === "timeout" + ? new Error(`${path.basename(command)} exceeded its ${timeoutMs}ms timeout`) + : new Error(`${path.basename(command)} exceeded the bounded output limit`); + const awaitQuiescence = () => { + quiescenceTimer = undefined; + let alive; + try { alive = processTreeIsAlive(child); } + catch (error) { settle(() => reject(error)); return; } + if (!alive) { + settle(() => reject(terminationError())); + return; + } + if (forceSent && Date.now() >= quiescenceDeadline) { + settle(() => reject(new Error( + `${path.basename(command)} process group did not quiesce after SIGKILL`, + ))); + return; + } + quiescenceTimer = setTimeout(awaitQuiescence, QUIESCENCE_POLL_MS); + }; + const terminate = (reason) => { + if (termination !== undefined) return; + termination = { reason }; + try { + signalProcessGroup(child, "SIGTERM"); + forceTimer = setTimeout(() => { + try { + forceSent = true; + quiescenceDeadline = Date.now() + QUIESCENCE_TIMEOUT_MS; + signalProcessGroup(child, "SIGKILL"); + } catch (error) { + settle(() => reject(error)); + } + }, TERMINATION_GRACE_MS); + awaitQuiescence(); + } catch (error) { + settle(() => reject(error)); + } + }; + const retain = (current, chunk) => { + if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(chunk, "utf8") + > maxOutputBytes) { + terminate("output"); + return current; + } + return current + chunk; + }; + + timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout = retain(stdout, chunk); }); + child.stderr.on("data", (chunk) => { stderr = retain(stderr, chunk); }); + child.once("error", (error) => settle(() => reject(error))); + child.once("close", (code) => { + if (termination !== undefined) { + if (quiescenceTimer === undefined) awaitQuiescence(); + return; + } + if (code !== 0 && options.allowNonzero !== true) { + settle(() => reject(new Error( + `${path.basename(command)} ${args.join(" ")} failed (${code})${stderr ? `\n${stderr.trim()}` : ""}`, + ))); + return; + } + settle(() => resolve({ code: code ?? 1, stderr, stdout })); + }); +}); diff --git a/scripts/simfile-local-example.mjs b/scripts/simfile-local-example.mjs new file mode 100644 index 0000000..d524009 --- /dev/null +++ b/scripts/simfile-local-example.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { runBoundedProcess } from "./bounded-process.mjs"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const createLocalExampleInvocation = (nonce = randomUUID()) => { + if (!/^[a-f0-9-]{8,64}$/u.test(nonce)) { + throw new TypeError("Local example nonce is invalid"); + } + const runId = `example-local-${nonce}`; + const out = path.join("runs", runId); + return Object.freeze({ + args: Object.freeze([ + path.join(packageRoot, "dist", "cli", "index.js"), + "run", + path.join(packageRoot, "examples", "jungian-dialogue", "Simfile"), + "--local", "--ticks", "12", "--run-id", runId, "--out", out, + ]), + out, + run_id: runId, + }); +}; + +export const runLocalExample = async (nonce) => { + const invocation = createLocalExampleInvocation(nonce); + const result = await runBoundedProcess(process.execPath, invocation.args, { + cwd: packageRoot, + env: process.env, + timeoutMs: 10 * 60 * 1000, + }); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + return invocation; +}; + +if (process.argv[1] !== undefined + && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { await runLocalExample(); } + catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/simfile-local-example.test.mjs b/scripts/simfile-local-example.test.mjs new file mode 100644 index 0000000..24b98f9 --- /dev/null +++ b/scripts/simfile-local-example.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { createLocalExampleInvocation } from "./simfile-local-example.mjs"; + +test("local example uses the canonical project and a unique bounded output", () => { + const first = createLocalExampleInvocation("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + const second = createLocalExampleInvocation("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + assert.notEqual(first.run_id, second.run_id); + assert.notEqual(first.out, second.out); + assert.equal(first.args.includes("--local"), true); + assert.deepEqual(first.args.slice(first.args.indexOf("--ticks"), -4), ["--ticks", "12"]); + assert.equal(first.args[2]?.endsWith(path.join("examples", "jungian-dialogue", "Simfile")), + true); + assert.throws(() => createLocalExampleInvocation("../escape"), /nonce is invalid/u); +}); diff --git a/scripts/spawnfile-capability-probe.mjs b/scripts/spawnfile-capability-probe.mjs new file mode 100644 index 0000000..0bffbf6 --- /dev/null +++ b/scripts/spawnfile-capability-probe.mjs @@ -0,0 +1,126 @@ +export const PROBE_VERSION = "simfile.spawnfile-public-capability-probe.v1"; +export const CAPABILITIES_VERSION = "spawnfile.capabilities.v1"; +export const COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION = + "spawnfile.composed-lifecycle-contract-set.v1"; +const ADMITTED_PACKAGE_VERSION = "0.1.17"; +const ADMITTED_ROWS_SHA256 = "095db48660b286add81b00bdb084edc457f57b29c1c5b8a59c312e02560c4146"; + +const semanticVersion = (value) => /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value); +const versionedIdentifier = (value) => /^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u.test(value); +const helpHasToken = (source, token) => source.split(/\r?\n/u).some((line) => { + const normalized = line.trim(); + return normalized === token || normalized.startsWith(`${token} `); +}); +const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` + : value !== null && typeof value === "object" ? `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); +const rowsDigest = (rows) => createHash("sha256") + .update(`simfile.spawnfile-capability-contract.v1\0${canonical(rows)}`, "utf8").digest("hex"); + +const parseCapabilities = (source) => { + let value; + try { value = JSON.parse(source); } + catch { throw new Error("Spawnfile capabilities did not emit JSON"); } + if (value?.version !== CAPABILITIES_VERSION + || value?.implementation?.cli !== "spawnfile" + || value?.implementation?.package !== "spawnfile" + || value?.implementation?.version !== ADMITTED_PACKAGE_VERSION + || value?.capabilities?.composed_lifecycle?.command_set_version !== COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION + || value?.capabilities?.composed_lifecycle?.complete !== true + || value?.capabilities?.target_config_resolver?.output_version !== "spawnfile.target-config-resolution.v1" + || value?.capabilities?.target_config_resolver?.target_config_version !== "spawnfile.target-default-config.v1" + || value?.capabilities?.evidence_export_helper?.identity !== "docker-image-config-digest" + || value?.capabilities?.evidence_export_helper?.local_context_only !== true + || JSON.stringify(value?.capabilities?.evidence_export_helper?.prepare_command) !== JSON.stringify(["helper", "prepare-evidence-export", "--context", "", "--json"]) + || value?.capabilities?.evidence_export_helper?.receipt_version !== "spawnfile.target-evidence-export-helper.prepared.v1" + || value?.capabilities?.evidence_export_helper?.resolver_option !== "--prepare-evidence-helper" + || value?.capabilities?.evidence_export_helper?.provisioning !== "spawnfile-owned-target-local" + || value?.capabilities?.terminal_public_artifact?.request_version !== "spawnfile.target-public-artifact-snapshot.request.v1" + || value?.capabilities?.terminal_public_artifact?.snapshot_version !== "spawnfile.target-public-artifact-snapshot.v1" + || value?.capabilities?.terminal_public_artifact?.not_present_version !== "spawnfile.target-public-artifact-snapshot.not-present.v1") { + throw new Error("Spawnfile generic capabilities receipt is invalid"); + } + const candidates = ["command_rows", "commands", "operations", "rows"] + .filter((key) => Array.isArray(value.capabilities.composed_lifecycle[key])); + if (candidates.length !== 1 || value.capabilities.composed_lifecycle[candidates[0]].length !== 43) { + throw new Error("Spawnfile generic capabilities command set is invalid"); + } + const fields = [ + "argv", "stdin_versions", "request_versions", "receipt_versions", + "invocation_versions", "pending_versions", "stdout", + ]; + const rows = value.capabilities.composed_lifecycle[candidates[0]]; + for (const row of rows) { + if (row === null || typeof row !== "object" || !fields.every((field) => Object.hasOwn(row, field)) + || !Array.isArray(row.argv) || row.argv.length === 0 || row.argv.some((arg) => typeof arg !== "string" || !arg) + || !["stdin_versions", "request_versions", "receipt_versions", "invocation_versions", "pending_versions"] + .every((field) => Array.isArray(row[field]) && row[field].every(versionedIdentifier))) { + throw new Error("Spawnfile generic capabilities command row is invalid"); + } + } + if (rowsDigest(rows) !== ADMITTED_ROWS_SHA256) { + throw new Error("Spawnfile generic capabilities command contract drifted"); + } + return Object.freeze({ + command_count: rows.length, + command_set_version: value.capabilities.composed_lifecycle.command_set_version, + implementation: Object.freeze({ ...value.implementation }), + version: value.version, + }); +}; + +export const createSpawnfileCapabilityProbe = (input) => { + const version = input.version.trim(); + if (!semanticVersion(version)) throw new Error("Spawnfile did not report a semantic version"); + const legacyDiscovery = input.capabilities_json === undefined; + const commands = legacyDiscovery + ? { + compile: helpHasToken(input.root_help, "compile"), + target: helpHasToken(input.root_help, "target"), + validate: helpHasToken(input.root_help, "validate"), + resolve_config: helpHasToken(input.target_help, "resolve_config"), + snapshot_public_artifact: helpHasToken(input.target_help, "snapshot_public_artifact"), + } + : { capabilities: true }; + const resolver = legacyDiscovery + ? { + evidence_destination: helpHasToken(input.resolver_help, "--evidence-destination"), + prepared_plan: helpHasToken(input.resolver_help, "--prepared-plan"), + } + : { generic_capabilities_receipt: true }; + const blockers = legacyDiscovery + ? Object.entries(commands).filter(([, available]) => !available) + .map(([name]) => `generic_command_unavailable:${name}`) + : []; + if (legacyDiscovery && !resolver.evidence_destination) { + blockers.push("generic_resolver_option_unavailable:evidence_destination"); + } + if (legacyDiscovery && !resolver.prepared_plan) { + blockers.push("generic_resolver_option_unavailable:prepared_plan"); + } + let capabilities; + if (legacyDiscovery) { + blockers.push( + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ); + } else { + capabilities = parseCapabilities(input.capabilities_json); + if (capabilities.implementation.version !== version) { + blockers.push("capabilities_implementation_version_mismatch"); + } + } + return Object.freeze({ + ...(capabilities === undefined ? {} : { capabilities }), + commands: Object.freeze(commands), + composed: Object.freeze({ blockers: Object.freeze(blockers), ready: blockers.length === 0 }), + development: Object.freeze({ + ready: legacyDiscovery ? commands.compile && commands.validate : true, + }), + implementation: Object.freeze({ package: "spawnfile", version }), + resolver: Object.freeze(resolver), + version: PROBE_VERSION, + }); +}; +import { createHash } from "node:crypto"; diff --git a/scripts/spawnfile-composed-smoke.mjs b/scripts/spawnfile-composed-smoke.mjs new file mode 100755 index 0000000..0b203ac --- /dev/null +++ b/scripts/spawnfile-composed-smoke.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + probeSpawnfileCapabilities, + readCurrentState, +} from "./spawnfile-development.mjs"; +import { runBoundedProcess } from "./bounded-process.mjs"; +import { proveSpawnfileLocalEndpoint } from "./spawnfile-local-endpoint.mjs"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const builtCli = path.join(packageRoot, "dist", "cli", "index.js"); +const composedExample = path.join(packageRoot, "examples", "jungian-dialogue", "Simfile"); +const internalSmokeExample = path.join( + packageRoot, "examples", "composed-development", "Simfile", +); +const fail = (message) => { throw new Error(message); }; +const takeValue = (argv, index, flag) => { + const arg = argv[index]; + if (arg === flag) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) return fail(`${flag} requires a value`); + return { consumed: 2, value }; + } + if (arg?.startsWith(`${flag}=`)) { + const value = arg.slice(flag.length + 1); + if (!value) return fail(`${flag} requires a value`); + return { consumed: 1, value }; + } + return undefined; +}; + +export const parseSmokeRunArguments = (argv) => { + let context; + let baseImage; + let dockerCommand; + let internalLifecycleSmoke = false; + const simfileArgs = []; + const values = ["--out", "--run-id", "--seed"]; + for (let index = 0; index < argv.length;) { + const ownFlags = [ + ["--context", "context"], + ["--base-image", "baseImage"], + ["--docker-command", "dockerCommand"], + ]; + let matched = false; + for (const [flag, key] of ownFlags) { + const parsed = takeValue(argv, index, flag); + if (parsed === undefined) continue; + if ({ context, baseImage, dockerCommand }[key] + !== undefined) return fail(`Duplicate ${flag}`); + if (key === "context") context = parsed.value; + if (key === "baseImage") baseImage = parsed.value; + if (key === "dockerCommand") dockerCommand = parsed.value; + index += parsed.consumed; + matched = true; + break; + } + if (matched) continue; + if (argv[index] === "--internal-lifecycle-smoke") { + if (internalLifecycleSmoke) return fail("Duplicate --internal-lifecycle-smoke"); + internalLifecycleSmoke = true; + index += 1; + continue; + } + if (argv[index] === "--view") { + if (simfileArgs.includes("--view")) return fail("Duplicate --view"); + simfileArgs.push("--view"); + index += 1; + continue; + } + for (const flag of values) { + const parsed = takeValue(argv, index, flag); + if (parsed === undefined) continue; + if (simfileArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`))) { + return fail(`Duplicate ${flag}`); + } + simfileArgs.push(flag, parsed.value); + index += parsed.consumed; + matched = true; + break; + } + if (!matched) return fail(`Unknown smoke-run option ${argv[index] ?? ""}`.trim()); + } + if (context === undefined || !/^[a-z][a-z0-9_-]{0,63}$/u.test(context)) { + return fail("Smoke run requires --context "); + } + return { baseImage, context, dockerCommand, internalLifecycleSmoke, simfileArgs }; +}; + +const argumentValue = (args, flag) => { + const index = args.findIndex((value) => value === flag || value.startsWith(`${flag}=`)); + if (index === -1) return undefined; + return args[index].startsWith(`${flag}=`) ? args[index].slice(flag.length + 1) : args[index + 1]; +}; + +export const createComposedSmokeInvocation = (argv, nonce = randomUUID()) => { + if (!/^[a-f0-9-]{8,64}$/u.test(nonce)) return fail("Composed example nonce is invalid"); + const parsed = parseSmokeRunArguments(argv); + const example = parsed.internalLifecycleSmoke ? internalSmokeExample : composedExample; + const runId = argumentValue(parsed.simfileArgs, "--run-id") + ?? `${parsed.internalLifecycleSmoke ? "composed-lifecycle-smoke" : "jungian-dialogue"}-${nonce}`; + const out = argumentValue(parsed.simfileArgs, "--out") ?? path.join("runs", runId); + const simfileArgs = [...parsed.simfileArgs]; + if (argumentValue(simfileArgs, "--run-id") === undefined) simfileArgs.push("--run-id", runId); + if (argumentValue(simfileArgs, "--out") === undefined) simfileArgs.push("--out", out); + simfileArgs.push("--context", parsed.context); + simfileArgs.push("--mode", "lifecycle-replay-smoke"); + const commandArgs = Object.freeze([ + builtCli, "run", example, ...simfileArgs, + ]); + return Object.freeze({ + ...parsed, + command: process.execPath, + command_args: commandArgs, + example, + mode: "lifecycle-replay-smoke", + out, + run_id: runId, + simfileArgs: Object.freeze(simfileArgs), + }); +}; + +export const runComposedDevelopmentSmoke = async (argv) => { + const invocation = createComposedSmokeInvocation(argv); + const state = await readCurrentState(); + const probe = await probeSpawnfileCapabilities(state.bin); + if (!probe.composed.ready) { + return fail( + `Simfile cannot verify the generic Spawnfile capabilities required for composition (${probe.composed.blockers.join(", ")}); ` + + `Spawnfile ${state.implementation.package_version}, context ${invocation.context}, ` + + `mode ${invocation.mode}, planned output ${invocation.out}`, + ); + } + if (state.implementation.package_version !== "0.1.17") { + return fail("Composed development requires the exact installed Spawnfile 0.1.17 package"); + } + const environment = { + ...process.env, + SPAWNFILE_BIN: state.bin, + ...(invocation.baseImage === undefined ? {} + : { SIMFILE_SPAWNFILE_BASE_IMAGE: invocation.baseImage }), + ...(invocation.dockerCommand === undefined ? {} + : { SIMFILE_SPAWNFILE_DOCKER_COMMAND: invocation.dockerCommand }), + }; + const endpoint = await proveSpawnfileLocalEndpoint({ + base_image: invocation.baseImage, + context: invocation.context, + cwd: packageRoot, + docker_command: invocation.dockerCommand, + env: environment, + spawnfile_bin: state.bin, + state_root: path.join(packageRoot, ".simfile-dev", "spawnfile"), + }); + process.stderr.write(`Spawnfile ${state.implementation.package_version}; local context ` + + `${endpoint.context} (${endpoint.transport}/${endpoint.architecture}); ` + + `run ${invocation.run_id}; output ${invocation.out}\n`); + const result = await runBoundedProcess(invocation.command, invocation.command_args, { + allowNonzero: true, + cwd: packageRoot, + env: environment, + timeoutMs: 20 * 60 * 1000, + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + return result.code; +}; + +if (process.argv[1] !== undefined + && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { process.exitCode = await runComposedDevelopmentSmoke(process.argv.slice(2)); } + catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/spawnfile-composed-smoke.test.mjs b/scripts/spawnfile-composed-smoke.test.mjs new file mode 100644 index 0000000..927c86f --- /dev/null +++ b/scripts/spawnfile-composed-smoke.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createComposedSmokeInvocation, + parseSmokeRunArguments, +} from "./spawnfile-composed-smoke.mjs"; + +test("composed smoke runner requires one explicit portable local target", () => { + assert.deepEqual(parseSmokeRunArguments([ + "--context", "local-dev", + "--out", "runs/smoke", + "--view", + ]), { + baseImage: undefined, + context: "local-dev", + dockerCommand: undefined, + internalLifecycleSmoke: false, + simfileArgs: ["--out", "runs/smoke", "--view"], + }); + assert.throws(() => parseSmokeRunArguments([]), /--context/u); + assert.throws(() => parseSmokeRunArguments(["--context", "LOCAL"]), + /safe-local-context/u); + assert.throws(() => parseSmokeRunArguments([ + "--context", "local-dev", "--mode", "live", + ]), /Unknown/u); +}); + +test("composed smoke runner pins its mode and unique default run output", () => { + const first = createComposedSmokeInvocation( + ["--context", "local-dev"], "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + ); + const second = createComposedSmokeInvocation( + ["--context", "local-dev"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + ); + assert.equal(first.mode, "lifecycle-replay-smoke"); + assert.notEqual(first.run_id, second.run_id); + assert.notEqual(first.out, second.out); + assert.deepEqual(first.simfileArgs.slice(-2), ["--mode", "lifecycle-replay-smoke"]); + assert.equal(first.command, process.execPath); + assert.match(first.command_args[0], /[/\\]dist[/\\]cli[/\\]index[.]js$/u); + assert.equal(first.command_args[1], "run"); + assert.match(first.command_args[2], + /[/\\]examples[/\\]jungian-dialogue[/\\]Simfile$/u); + assert.equal(first.example, first.command_args[2]); + assert.deepEqual(first.command_args.slice(3), first.simfileArgs); + const explicit = createComposedSmokeInvocation([ + "--context", "local-dev", "--run-id", "chosen", "--out", "runs/chosen", + ], "cccccccc-cccc-cccc-cccc-cccccccccccc"); + assert.equal(explicit.run_id, "chosen"); + assert.equal(explicit.out, "runs/chosen"); +}); + +test("the former one-agent project is only selected by the explicit internal flag", () => { + const internal = createComposedSmokeInvocation([ + "--context", "local-dev", "--internal-lifecycle-smoke", + ], "dddddddd-dddd-dddd-dddd-dddddddddddd"); + assert.match(internal.example, + /[/\\]examples[/\\]composed-development[/\\]Simfile$/u); + assert.match(internal.run_id, /^composed-lifecycle-smoke-/u); + assert.equal(internal.internalLifecycleSmoke, true); +}); diff --git a/scripts/spawnfile-development-context.mjs b/scripts/spawnfile-development-context.mjs new file mode 100644 index 0000000..d3abd77 --- /dev/null +++ b/scripts/spawnfile-development-context.mjs @@ -0,0 +1,87 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createSpawnfileCapabilityProbe, PROBE_VERSION } from + "./spawnfile-capability-probe.mjs"; +import { runBoundedProcess } from "./bounded-process.mjs"; +import { + assertInstalledArtifact, + assertOrigin, + executableAt, + probeIdentity, +} from "./spawnfile-install-integrity.mjs"; + +export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const developmentRoot = path.join(packageRoot, ".simfile-dev", "spawnfile"); +export const installsRoot = path.join(developmentRoot, "installs"); +export const currentPath = path.join(developmentRoot, "current.json"); +export const linkedExample = path.join( + packageRoot, "examples", "jungian-dialogue", "org", "Spawnfile", +); +export const STATE_VERSION = "simfile.spawnfile-development-state.v3"; +export const CHECK_VERSION = "simfile.spawnfile-development-check.v1"; + +export const fail = (message) => { throw new Error(message); }; +export const run = (command, args, options = {}) => runBoundedProcess(command, args, { + ...options, + cwd: options.cwd ?? packageRoot, + env: options.env ?? process.env, +}); + +const readJson = async (filePath) => { + try { return JSON.parse(await readFile(filePath, "utf8")); } + catch (error) { + return fail(`Unable to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const probeSpawnfileCapabilities = async (bin, runCommand = run) => { + const [version, capabilities] = await Promise.all([ + runCommand(bin, ["--version"]), + runCommand(bin, ["capabilities", "--json"]).then(({ stdout }) => stdout) + .catch(() => undefined), + ]); + if (capabilities !== undefined) { + return createSpawnfileCapabilityProbe({ capabilities_json: capabilities, + resolver_help: "", root_help: "", target_help: "", version: version.stdout }); + } + const unavailableHelp = { stdout: "" }; + const [rootHelp, targetHelp, resolverHelp] = await Promise.all([ + runCommand(bin, ["--help"]), + runCommand(bin, ["target", "--help"]).catch(() => unavailableHelp), + runCommand(bin, ["target", "resolve_config", "--help"]).catch(() => unavailableHelp), + ]); + return createSpawnfileCapabilityProbe({ resolver_help: resolverHelp.stdout, + root_help: rootHelp.stdout, target_help: targetHelp.stdout, version: version.stdout }); +}; + +export const readCurrentState = async () => { + const value = await readJson(currentPath); + if (value?.version !== STATE_VERSION || typeof value.bin !== "string" + || typeof value.install_root !== "string" || !path.isAbsolute(value.install_root) + || !value.install_root.startsWith(`${installsRoot}${path.sep}`) + || value.bin !== executableAt(value.install_root) + || typeof value.implementation?.package_version !== "string" + || !/^[0-9a-f]{64}$/u.test(value.implementation?.tarball_sha256 ?? "") + || !/^[0-9a-f]{64}$/u.test(value.implementation?.executable_sha256 ?? "") + || !/^[0-9a-f]{64}$/u.test(value.implementation?.installed_closure_sha256 ?? "") + || value.capability_probe?.version !== PROBE_VERSION + || !/^[0-9a-f]{64}$/u.test(value.capability_probe?.sha256 ?? "")) { + return fail("Spawnfile development state is invalid; rerun dev:spawnfile:setup"); + } + await assertOrigin(value.origin); + await assertInstalledArtifact(value.install_root, value.implementation); + const probe = await probeSpawnfileCapabilities(value.bin); + if (probeIdentity(probe).sha256 !== value.capability_probe.sha256) { + return fail("Spawnfile capability probe drifted; rerun dev:spawnfile:setup"); + } + return Object.freeze({ + bin: value.bin, capability_probe: probe, + capability_probe_identity: value.capability_probe, + implementation: value.implementation, install_root: value.install_root, + origin: value.origin, version: value.version, + }); +}; + +export { createSpawnfileCapabilityProbe, PROBE_VERSION }; diff --git a/scripts/spawnfile-development-setup.mjs b/scripts/spawnfile-development-setup.mjs new file mode 100644 index 0000000..1536ceb --- /dev/null +++ b/scripts/spawnfile-development-setup.mjs @@ -0,0 +1,175 @@ +import { randomUUID } from "node:crypto"; +import { + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + currentPath, + developmentRoot, + fail, + installsRoot, + probeSpawnfileCapabilities, + run, + STATE_VERSION, +} from "./spawnfile-development-context.mjs"; +import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; +import { + assertInstalledArtifact, + hash, + packagedTarballAt, + probeIdentity, +} from "./spawnfile-install-integrity.mjs"; + +export const parseSetupArguments = (args) => { + let source; + let packageSpec; + let artifact; + let sha256; + for (let index = 0; index < args.length; index += 1) { + const flag = args[index]; + if (!["--artifact", "--package", "--sha256", "--source"].includes(flag)) { + fail(`Unknown setup option ${flag ?? ""}`.trim()); + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) fail(`${flag} requires a value`); + if (flag === "--source") source = value; + else if (flag === "--package") packageSpec = value; + else if (flag === "--artifact") artifact = value; + else sha256 = value; + index += 1; + } + if ([source, packageSpec, artifact].filter((value) => value !== undefined).length !== 1) { + fail("Setup requires exactly one of --source, --package, or --artifact"); + } + if (source !== undefined && (!path.isAbsolute(source) || path.normalize(source) !== source)) { + fail("--source must be an absolute normalized Spawnfile checkout path"); + } + if (packageSpec !== undefined + && !/^spawnfile@[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(packageSpec)) { + fail("--package must be an exact spawnfile@ coordinate"); + } + if (artifact !== undefined && (!path.isAbsolute(artifact) || path.normalize(artifact) !== artifact)) { + fail("--artifact must be an absolute normalized Spawnfile tarball path"); + } + if (artifact !== undefined && !/^[0-9a-f]{64}$/u.test(sha256 ?? "")) { + fail("--artifact requires --sha256 with an exact lowercase SHA-256 digest"); + } + if (artifact === undefined && sha256 !== undefined) fail("--sha256 is valid only with --artifact"); + return { artifact, packageSpec, sha256, source }; +}; + +const parsePackResult = (stdout) => { + let value; + try { value = JSON.parse(stdout); } + catch { return fail("npm pack did not return JSON"); } + if (!Array.isArray(value) || value.length !== 1 + || typeof value[0]?.filename !== "string" || typeof value[0]?.integrity !== "string" + || typeof value[0]?.version !== "string") { + return fail("npm pack did not report exactly one Spawnfile tarball"); + } + return value[0]; +}; + +const installPackage = async (spec, temporaryRoot) => { + await writeFile(path.join(temporaryRoot, "package.json"), `${JSON.stringify({ + name: "simfile-spawnfile-development-tool", private: true, version: "0.0.0", + }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", + "--no-package-lock", "--save-exact", spec], { cwd: temporaryRoot }); +}; + +const packedSelection = async (input, temporaryRoot) => { + if (input.artifact !== undefined) { + const info = await lstat(input.artifact).catch(() => fail("Spawnfile artifact is missing")); + if (!info.isFile() || info.isSymbolicLink()) fail("Spawnfile artifact must be a regular file"); + const tarballHash = hash(await readFile(input.artifact)); + if (tarballHash !== input.sha256) fail("Spawnfile artifact SHA-256 did not match --sha256"); + const manifestText = (await run("tar", ["-xOf", input.artifact, "package/package.json"])).stdout; + let manifest; + try { manifest = JSON.parse(manifestText); } catch { fail("Spawnfile artifact package metadata is invalid"); } + if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { + fail("Spawnfile artifact is not a versioned spawnfile package"); + } + return { identity: `artifact-v1:${tarballHash}`, installSpec: input.artifact, + origin: { kind: "artifact", package_version: manifest.version, + path: input.artifact, sha256: tarballHash }, + tarball: input.artifact, tarball_sha256: tarballHash }; + } + let packCwd; + let packSpec; + let origin; + if (input.source !== undefined) { + const staged = await stagePhysicalSpawnfileSource(input.source, temporaryRoot); + await run("npm", ["ci", "--no-audit", "--no-fund"], { cwd: staged.staging }); + await run("npm", ["run", "build"], { cwd: staged.staging }); + packCwd = staged.staging; + origin = { kind: "source", package_version: staged.origin.package_version, + path: staged.origin.path }; + } else { + packSpec = input.packageSpec; + } + const packRoot = path.join(temporaryRoot, "pack"); + await mkdir(packRoot, { mode: 0o700 }); + const args = ["pack", ...(packSpec === undefined ? [] : [packSpec]), + "--json", "--pack-destination", packRoot]; + const packed = parsePackResult((await run("npm", args, + packCwd === undefined ? {} : { cwd: packCwd })).stdout); + const tarball = path.join(packRoot, packed.filename); + const tarballHash = hash(await readFile(tarball)); + return { identity: `${input.source === undefined ? "registry" : "source"}-v2:${tarballHash}`, + installSpec: tarball, + origin: origin ?? { kind: "registry", package_version: packed.version, spec: input.packageSpec }, + tarball, tarball_sha256: tarballHash }; +}; + +const writeCurrentState = async (state) => { + await mkdir(developmentRoot, { recursive: true, mode: 0o700 }); + const pending = path.join(developmentRoot, `.current-${randomUUID()}.json`); + await writeFile(pending, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(pending, currentPath); +}; + +export const setupSpawnfileDevelopment = async (args) => { + const options = parseSetupArguments(args); + await mkdir(installsRoot, { recursive: true, mode: 0o700 }); + const temporaryRoot = await mkdtemp(path.join(developmentRoot, ".install-")); + try { + const selected = await packedSelection(options, temporaryRoot); + const installRoot = path.join(installsRoot, hash(selected.identity).slice(0, 32)); + try { await lstat(installRoot); } + catch (error) { + if (error?.code !== "ENOENT") throw error; + const staged = path.join(temporaryRoot, "installed"); + await mkdir(staged, { mode: 0o700 }); + await installPackage(selected.installSpec, staged); + await copyFile(selected.tarball, packagedTarballAt(staged), 0); + await assertInstalledArtifact(staged, { package_version: selected.origin.package_version, + repair_permissions: true, tarball_sha256: selected.tarball_sha256 }); + await rename(staged, installRoot); + } + const installed = await assertInstalledArtifact(installRoot, { + package_version: selected.origin.package_version, + tarball_sha256: selected.tarball_sha256, + }); + const probe = await probeSpawnfileCapabilities(installed.executable); + if (!probe.development.ready) fail("Installed Spawnfile lacks required generic development commands"); + const state = { bin: installed.executable, capability_probe: probeIdentity(probe), + implementation: { executable_sha256: installed.executable_sha256, + installed_closure_sha256: installed.installed_closure_sha256, + package_version: installed.package_version, tarball_sha256: installed.tarball_sha256 }, + install_root: installRoot, origin: selected.origin, version: STATE_VERSION }; + await writeCurrentState(state); + process.stdout.write(`${JSON.stringify({ ...state, capability_probe: probe, + capability_probe_identity: state.capability_probe }, null, 2)}\n`); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}; diff --git a/scripts/spawnfile-development.mjs b/scripts/spawnfile-development.mjs new file mode 100644 index 0000000..c8e3585 --- /dev/null +++ b/scripts/spawnfile-development.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + CHECK_VERSION, + PROBE_VERSION, + STATE_VERSION, + createSpawnfileCapabilityProbe, + developmentRoot, + fail, + linkedExample, + packageRoot, + probeSpawnfileCapabilities, + readCurrentState, + run, +} from "./spawnfile-development-context.mjs"; +import { parseSetupArguments, setupSpawnfileDevelopment } from + "./spawnfile-development-setup.mjs"; + +const check = async () => { + const state = await readCurrentState(); + const probe = state.capability_probe; + if (!probe.development.ready) fail("Installed Spawnfile lacks required generic development commands"); + const checkRoot = await mkdtemp(path.join(developmentRoot, ".check-")); + try { + await run(state.bin, ["validate", linkedExample]); + await run(state.bin, ["compile", linkedExample, "--out", path.join(checkRoot, "compiled")]); + } finally { + await rm(checkRoot, { recursive: true, force: true }); + } + process.stdout.write(`${JSON.stringify({ + capability_probe: probe, + example: path.relative(packageRoot, linkedExample), + example_compilation: { state: "compiled" }, + example_validation: { state: "valid" }, + version: CHECK_VERSION, + }, null, 2)}\n`); +}; + +const main = async (args) => { + const [command, ...rest] = args; + if (command === "setup") return setupSpawnfileDevelopment(rest); + if (command === "check" && rest.length === 0) return check(); + if (command === "status" && rest.length === 0) { + const state = await readCurrentState(); + process.stdout.write(`${JSON.stringify(state, null, 2)}\n`); + return; + } + fail("Usage: spawnfile-development.mjs "); +}; + +export { + CHECK_VERSION, + PROBE_VERSION, + STATE_VERSION, + createSpawnfileCapabilityProbe, + parseSetupArguments, + probeSpawnfileCapabilities, + readCurrentState, + run, +}; + +if (process.argv[1] !== undefined + && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { await main(process.argv.slice(2)); } + catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/spawnfile-development.test.mjs b/scripts/spawnfile-development.test.mjs new file mode 100644 index 0000000..60403b6 --- /dev/null +++ b/scripts/spawnfile-development.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; + +import { + createSpawnfileCapabilityProbe, + parseSetupArguments, + probeSpawnfileCapabilities, + run, +} from "./spawnfile-development.mjs"; + +const assertProcessGroupStopped = (pid) => { + assert.throws( + () => process.kill(-pid, 0), + (error) => error?.code === "ESRCH", + ); +}; + +test("spawnfile development setup requires one explicit standalone source", () => { + assert.deepEqual(parseSetupArguments(["--source", "/tmp/spawnfile-source"]), { + artifact: undefined, + packageSpec: undefined, + sha256: undefined, + source: "/tmp/spawnfile-source", + }); + assert.deepEqual(parseSetupArguments(["--package", "spawnfile@0.2.0"]), { + artifact: undefined, + packageSpec: "spawnfile@0.2.0", + sha256: undefined, + source: undefined, + }); + assert.deepEqual(parseSetupArguments(["--artifact", "/tmp/spawnfile.tgz", "--sha256", "a".repeat(64)]), { + artifact: "/tmp/spawnfile.tgz", packageSpec: undefined, sha256: "a".repeat(64), source: undefined, + }); + assert.throws(() => parseSetupArguments([]), /exactly one/u); + assert.throws(() => parseSetupArguments([ + "--source", "/tmp/spawnfile-source", "--package", "spawnfile@0.2.0", + ]), /exactly one/u); + assert.throws(() => parseSetupArguments(["--source", "../spawnfile"]), /absolute normalized/u); + assert.throws(() => parseSetupArguments(["--package", "spawnfile@latest"]), /exact/u); + assert.throws(() => parseSetupArguments(["--artifact", "/tmp/spawnfile.tgz"]), /requires --sha256/u); +}); + +test("Simfile probes only generic Spawnfile command surfaces and fails closed", () => { + const probe = createSpawnfileCapabilityProbe({ + resolver_help: [ + " --evidence-destination ", + " --prepared-plan ", + ].join("\n"), + root_help: [ + " compile [options] [path] Compile a project", + " target [options] Execute target operations", + " validate [path] Validate a project", + ].join("\n"), + target_help: [ + " resolve_config [options]", + " snapshot_public_artifact ", + ].join("\n"), + version: "0.1.14\n", + }); + assert.equal(probe.development.ready, true); + assert.equal(probe.composed.ready, false); + assert.deepEqual(probe.composed.blockers, [ + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ]); + assert.equal(JSON.stringify(probe).includes("profile"), false); + assert.throws(() => createSpawnfileCapabilityProbe({ + resolver_help: "", root_help: "", target_help: "", version: "latest", + }), /semantic version/u); +}); + +test("generic help discovery ignores presentation indentation", () => { + const probe = createSpawnfileCapabilityProbe({ + resolver_help: "\t--evidence-destination \n --prepared-plan \n", + root_help: "compile [options] [path]\n\ttarget [options]\n validate [path]\n", + target_help: "resolve_config [options]\n\tsnapshot_public_artifact \n", + version: "0.1.14", + }); + assert.equal(probe.development.ready, true); + assert.deepEqual(probe.composed.blockers, [ + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ]); +}); + +test("capability probing uses generic JSON discovery before its legacy help fallback", async () => { + const calls = []; + const output = new Map([ + ["--version", "0.1.14\n"], + ["--help", " compile [options] [path]\n target [options]\n validate [path]\n"], + ["target --help", " resolve_config [options]\n snapshot_public_artifact \n"], + ["target resolve_config --help", " --evidence-destination \n --prepared-plan \n"], + ]); + const probe = await probeSpawnfileCapabilities("/isolated/spawnfile", async (bin, args) => { + calls.push([bin, args]); + if (args.join(" ") === "capabilities --json") throw new Error("unsupported"); + return { stderr: "", stdout: output.get(args.join(" ")) ?? "" }; + }); + assert.deepEqual(calls, [ + ["/isolated/spawnfile", ["--version"]], + ["/isolated/spawnfile", ["capabilities", "--json"]], + ["/isolated/spawnfile", ["--help"]], + ["/isolated/spawnfile", ["target", "--help"]], + ["/isolated/spawnfile", ["target", "resolve_config", "--help"]], + ]); + assert.equal(probe.composed.ready, false); + assert.equal(probe.development.ready, true); +}); + +test("capability probing rejects a structurally valid but unpinned JSON contract", async () => { + const row = (index) => ({ + argv: [`command-${index}`], + invocation_versions: [], + pending_versions: [], + receipt_versions: ["spawnfile.generic-receipt.v1"], + request_versions: ["spawnfile.generic-request.v1"], + stdin_versions: [], + stdout: { format: "json" }, + }); + const report = { + capabilities: { + composed_lifecycle: { + command_rows: Array.from({ length: 43 }, (_, index) => row(index)), + command_set_version: "spawnfile.composed-lifecycle-contract-set.v1", + complete: true, + }, + evidence_export_helper: { + identity: "docker-image-config-digest", local_context_only: true, + prepare_command: ["helper", "prepare-evidence-export", "--context", "", "--json"], + provisioning: "spawnfile-owned-target-local", + receipt_version: "spawnfile.target-evidence-export-helper.prepared.v1", + resolver_option: "--prepare-evidence-helper", + }, + target_config_resolver: { + command: ["target", "resolve_config"], output_version: "spawnfile.target-config-resolution.v1", + prepared_plan_version: "spawnfile.target-config-prepared-plan.v1", + target_config_digest_version: "spawnfile.target-config-digest.v1", + target_config_version: "spawnfile.target-default-config.v1", + }, + terminal_public_artifact: { + not_present_version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + request_version: "spawnfile.target-public-artifact-snapshot.request.v1", + snapshot_version: "spawnfile.target-public-artifact-snapshot.v1", + }, + }, + implementation: { cli: "spawnfile", package: "spawnfile", version: "0.1.17" }, + version: "spawnfile.capabilities.v1", + }; + const calls = []; + await assert.rejects(probeSpawnfileCapabilities("/isolated/spawnfile", async (_bin, args) => { + calls.push(args); + if (args.join(" ") === "--version") return { stderr: "", stdout: "0.1.17\n" }; + if (args.join(" ") === "capabilities --json") { + return { stderr: "", stdout: JSON.stringify(report) }; + } + throw new Error("help must not be queried after a valid JSON contract"); + }), /command contract drifted/u); + assert.deepEqual(calls, [["--version"], ["capabilities", "--json"]]); +}); + +test("missing nested generic help remains a structured fail-closed result", async () => { + const probe = await probeSpawnfileCapabilities("/isolated/spawnfile", async (_bin, args) => { + if (args.join(" ") === "--version") return { stderr: "", stdout: "0.1.14\n" }; + if (args.join(" ") === "--help") { + return { + stderr: "", + stdout: " compile [options] [path]\n target [options]\n validate [path]\n", + }; + } + throw new Error("unsupported generic discovery command"); + }); + assert.equal(probe.composed.ready, false); + assert.deepEqual(probe.composed.blockers.slice(0, 4), [ + "generic_command_unavailable:resolve_config", + "generic_command_unavailable:snapshot_public_artifact", + "generic_resolver_option_unavailable:evidence_destination", + "generic_resolver_option_unavailable:prepared_plan", + ]); +}); + +test("development subprocess timeout quiesces a hostile process group before settling", { + skip: process.platform === "win32", +}, async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-development-timeout-")); + const marker = path.join(root, "late-marker"); + const pidFile = path.join(root, "group-pid"); + const descendant = [ + "process.on('SIGTERM', () => {});", + `setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "late"), 1250);`, + "setInterval(() => {}, 1000);", + ].join(" "); + const parent = `require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { stdio: "inherit" }); setInterval(() => {}, 1000);`; + try { + await assert.rejects(run(process.execPath, ["-e", parent], { timeoutMs: 100 }), + /exceeded its 100ms timeout/u); + assertProcessGroupStopped(Number(await readFile(pidFile, "utf8"))); + await delay(500); + await assert.rejects(access(marker)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("development output overflow quiesces a hostile process group before settling", { + skip: process.platform === "win32", +}, async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-development-output-")); + const marker = path.join(root, "late-marker"); + const pidFile = path.join(root, "group-pid"); + const descendant = [ + "process.on('SIGTERM', () => {});", + "process.send?.('ready');", + `setTimeout(() => require("node:fs").writeFileSync(${JSON.stringify(marker)}, "late"), 2000);`, + "setInterval(() => {}, 1000);", + ].join(" "); + const parent = `require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); const descendant = require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { stdio: ["ignore", "inherit", "inherit", "ipc"] }); descendant.once("message", () => process.stdout.write("x".repeat(2048))); setInterval(() => {}, 1000);`; + try { + await assert.rejects(run(process.execPath, ["-e", parent], { + maxOutputBytes: 1_024, + timeoutMs: 5_000, + }), /bounded output limit/u); + assertProcessGroupStopped(Number(await readFile(pidFile, "utf8"))); + await delay(500); + await assert.rejects(access(marker)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/spawnfile-install-integrity.mjs b/scripts/spawnfile-install-integrity.mjs new file mode 100644 index 0000000..e975866 --- /dev/null +++ b/scripts/spawnfile-install-integrity.mjs @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; +import { chmod, lstat, readFile, readdir, readlink, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { inspectPhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; + +const fail = (message) => { throw new Error(message); }; + +export const hash = (value) => createHash("sha256").update(value).digest("hex"); +export const executableAt = (root) => path.join(root, "node_modules", ".bin", "spawnfile"); +export const packagedTarballAt = (root) => path.join(root, "spawnfile.tgz"); +export const probeIdentity = (probe) => Object.freeze({ + sha256: hash(JSON.stringify(probe)), + version: probe.version, +}); + +export const installedClosureHash = async (installRoot) => { + const closureRoot = path.join(installRoot, "node_modules"); + const digest = createHash("sha256"); + const visit = async (directory, relativeRoot = "") => { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + for (const entry of entries) { + const relative = path.posix.join(relativeRoot, entry.name); + const absolute = path.join(directory, entry.name); + const info = await lstat(absolute); + if (info.isSymbolicLink()) { + digest.update(`L\0${relative}\0${await readlink(absolute)}\0`); + } else if (info.isDirectory()) { + digest.update(`D\0${relative}\0`); + await visit(absolute, relative); + } else if (info.isFile()) { + digest.update(`F\0${relative}\0${info.mode & 0o777}\0${info.size}\0`); + digest.update(await readFile(absolute)); + digest.update("\0"); + } else return fail("Installed Spawnfile closure contains an unsupported entry"); + } + }; + await visit(closureRoot); + return digest.digest("hex"); +}; + +const installedPackage = async (installRoot) => { + let manifest; + try { + manifest = JSON.parse(await readFile(path.join(installRoot, "node_modules", "spawnfile", "package.json"), "utf8")); + } catch (error) { + return fail(`Unable to read installed Spawnfile metadata: ${error instanceof Error ? error.message : String(error)}`); + } + if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { + return fail("Installed Spawnfile package metadata is invalid"); + } + return manifest; +}; + +const installedExecutable = async (installRoot, repairPermissions = false) => { + const executable = executableAt(installRoot); + const target = await realpath(executable).catch(() => fail("Installed Spawnfile executable is missing")); + const physicalRoot = await realpath(installRoot); + if (target !== physicalRoot && !target.startsWith(`${physicalRoot}${path.sep}`)) { + return fail("Installed Spawnfile executable escaped its isolated tool root"); + } + const info = await lstat(target); + if (!info.isFile()) return fail("Installed Spawnfile executable is not a regular file"); + if (repairPermissions) await chmod(target, info.mode | 0o100); + return { executable, target }; +}; + +export const assertInstalledArtifact = async (installRoot, expected) => { + const tarball = packagedTarballAt(installRoot); + const tarballInfo = await lstat(tarball).catch(() => fail("Installed Spawnfile tarball is missing")); + if (!tarballInfo.isFile() || tarballInfo.isSymbolicLink()) { + return fail("Installed Spawnfile tarball is not a regular file"); + } + const tarball_sha256 = hash(await readFile(tarball)); + if (expected.tarball_sha256 !== undefined && tarball_sha256 !== expected.tarball_sha256) { + return fail("Installed Spawnfile tarball digest drifted; rerun dev:spawnfile:setup"); + } + const manifest = await installedPackage(installRoot); + if (expected.package_version !== undefined && manifest.version !== expected.package_version) { + return fail("Installed Spawnfile package version drifted; rerun dev:spawnfile:setup"); + } + const executable = await installedExecutable(installRoot, expected.repair_permissions === true); + const executable_sha256 = hash(await readFile(executable.target)); + if (expected.executable_sha256 !== undefined && executable_sha256 !== expected.executable_sha256) { + return fail("Installed Spawnfile executable digest drifted; rerun dev:spawnfile:setup"); + } + const installed_closure_sha256 = await installedClosureHash(installRoot); + if (expected.installed_closure_sha256 !== undefined + && installed_closure_sha256 !== expected.installed_closure_sha256) { + return fail("Installed Spawnfile module closure drifted; rerun dev:spawnfile:setup"); + } + return Object.freeze({ + executable: executable.executable, + executable_sha256, + installed_closure_sha256, + package_version: manifest.version, + tarball_sha256, + }); +}; + +export const assertOrigin = async (origin) => { + if (origin?.kind === "artifact" && typeof origin.path === "string" + && path.isAbsolute(origin.path) && path.normalize(origin.path) === origin.path + && /^[0-9a-f]{64}$/u.test(origin.sha256 ?? "") + && typeof origin.package_version === "string") { + const info = await lstat(origin.path).catch(() => fail("Spawnfile artifact origin is missing")); + if (!info.isFile() || info.isSymbolicLink()) return fail("Spawnfile artifact origin is invalid"); + if (hash(await readFile(origin.path)) === origin.sha256) return; + return fail("Spawnfile artifact origin digest changed; rerun dev:spawnfile:setup"); + } + if (origin?.kind === "registry" && typeof origin.spec === "string" + && /^spawnfile@[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/u.test(origin.spec) + && typeof origin.package_version === "string") return; + if (origin?.kind === "source" && typeof origin.path === "string" + && typeof origin.package_version === "string") { + const current = await inspectPhysicalSpawnfileSource(origin.path); + if (current.package_version === origin.package_version) return; + return fail("Spawnfile source origin version changed; rerun dev:spawnfile:setup"); + } + return fail("Spawnfile development origin is invalid; rerun dev:spawnfile:setup"); +}; diff --git a/scripts/spawnfile-install-integrity.test.mjs b/scripts/spawnfile-install-integrity.test.mjs new file mode 100644 index 0000000..c79d256 --- /dev/null +++ b/scripts/spawnfile-install-integrity.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { assertInstalledArtifact, hash } from "./spawnfile-install-integrity.mjs"; + +test("installed Spawnfile artifact verification rejects a tampered tarball", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-install-integrity-")); + const executable = path.join(root, "node_modules", ".bin", "spawnfile"); + const tarball = path.join(root, "spawnfile.tgz"); + await mkdir(path.dirname(executable), { recursive: true }); + await mkdir(path.join(root, "node_modules", "spawnfile"), { recursive: true }); + await Promise.all([ + writeFile(executable, "#!/bin/sh\nexit 0\n"), + writeFile(tarball, "trusted tarball\n"), + writeFile(path.join(root, "node_modules", "spawnfile", "package.json"), + '{"name":"spawnfile","version":"1.2.3"}\n'), + ]); + await chmod(executable, 0o755); + const expected = { + executable_sha256: hash(await readFile(executable)), + package_version: "1.2.3", + tarball_sha256: hash("trusted tarball\n"), + }; + try { + const installed = await assertInstalledArtifact(root, expected); + const pinned = { ...expected, installed_closure_sha256: installed.installed_closure_sha256 }; + await writeFile(path.join(root, "node_modules", "spawnfile", "runtime.mjs"), "export {};\n"); + await assert.rejects(assertInstalledArtifact(root, pinned), /module closure drifted/u); + await rm(path.join(root, "node_modules", "spawnfile", "runtime.mjs")); + await writeFile(tarball, "tampered tarball\n"); + await assert.rejects(assertInstalledArtifact(root, pinned), /tarball digest drifted/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/scripts/spawnfile-local-endpoint.mjs b/scripts/spawnfile-local-endpoint.mjs new file mode 100644 index 0000000..01556d6 --- /dev/null +++ b/scripts/spawnfile-local-endpoint.mjs @@ -0,0 +1,59 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; + +import { runBoundedProcess } from "./bounded-process.mjs"; + +const digest = /^sha256:[a-f0-9]{64}$/u; +const contextName = /^[a-z][a-z0-9_-]{0,63}$/u; +const fail = (message) => { throw new TypeError(message); }; + +export const parseSpawnfileLocalEndpointProof = (raw, expectedContext) => { + const value = raw; + if (value === null || typeof value !== "object" || Array.isArray(value) + || value.version !== "spawnfile.target-config-resolution.v1" + || value.context_selection !== "explicit" + || value.endpoint?.class !== "local" + || !["fd", "npipe", "unix"].includes(value.endpoint?.transport) + || value.platform?.os !== "linux" + || !["amd64", "arm64"].includes(value.platform?.architecture) + || value.target_config?.context !== expectedContext + || value.target_config?.version !== "spawnfile.target-default-config.v1" + || !digest.test(value.target_config_digest ?? "") + || !digest.test(value.base_image?.config_digest ?? "")) { + return fail("Spawnfile did not prove the exact context is a local endpoint"); + } + return Object.freeze({ + architecture: value.platform.architecture, + context: expectedContext, + endpoint_class: "local", + transport: value.endpoint.transport, + version: "simfile.spawnfile-local-endpoint-proof.v1", + }); +}; + +export const proveSpawnfileLocalEndpoint = async (input) => { + if (typeof input.spawnfile_bin !== "string" || !path.isAbsolute(input.spawnfile_bin) + || path.normalize(input.spawnfile_bin) !== input.spawnfile_bin + || !contextName.test(input.context ?? "")) { + return fail("Local endpoint proof requires an absolute Spawnfile bin and exact context"); + } + const root = await mkdtemp(path.join(input.state_root, ".endpoint-proof-")); + try { + const args = ["target", "resolve_config", "--context", input.context, + "--evidence-destination", path.join(root, "world-evidence.tar"), + "--timeout-ms", "120000"]; + if (input.base_image !== undefined) args.push("--base-image", input.base_image); + if (input.docker_command !== undefined) { + args.push("--docker-command", input.docker_command); + } + const result = await runBoundedProcess(input.spawnfile_bin, args, { + cwd: input.cwd, env: input.env, timeoutMs: 120_000, + }); + let raw; + try { raw = JSON.parse(result.stdout); } + catch { return fail("Spawnfile local endpoint proof did not emit JSON"); } + return parseSpawnfileLocalEndpointProof(raw, input.context); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; diff --git a/scripts/spawnfile-local-endpoint.test.mjs b/scripts/spawnfile-local-endpoint.test.mjs new file mode 100644 index 0000000..881dd5d --- /dev/null +++ b/scripts/spawnfile-local-endpoint.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseSpawnfileLocalEndpointProof } from "./spawnfile-local-endpoint.mjs"; + +const receipt = { + base_image: { config_digest: `sha256:${"1".repeat(64)}`, reference: "node:22" }, + context_selection: "explicit", + endpoint: { class: "local", transport: "unix" }, + platform: { architecture: "amd64", os: "linux" }, + target_config: { context: "local_dev", version: "spawnfile.target-default-config.v1" }, + target_config_digest: `sha256:${"2".repeat(64)}`, + version: "spawnfile.target-config-resolution.v1", +}; + +test("local endpoint proof binds exact context and rejects remote classification", () => { + assert.deepEqual(parseSpawnfileLocalEndpointProof(receipt, "local_dev"), { + architecture: "amd64", context: "local_dev", endpoint_class: "local", + transport: "unix", version: "simfile.spawnfile-local-endpoint-proof.v1", + }); + for (const forged of [ + { ...receipt, endpoint: { class: "remote", transport: "unix" } }, + { ...receipt, target_config: { ...receipt.target_config, context: "other" } }, + { ...receipt, context_selection: "default" }, + ]) assert.throws(() => parseSpawnfileLocalEndpointProof(forged, "local_dev"), + /local endpoint/u); +}); diff --git a/scripts/spawnfile-source-stage.mjs b/scripts/spawnfile-source-stage.mjs new file mode 100644 index 0000000..492272d --- /dev/null +++ b/scripts/spawnfile-source-stage.mjs @@ -0,0 +1,55 @@ +import { cp, lstat, mkdir, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; + +const omittedDirectories = new Set([ + ".artifacts", + ".git", + ".runtime", + ".sim", + ".simfile-dev", + ".spawn", + ".spawn-dev", + "coverage", + "dist", + "node_modules", + "runs", +]); + +const fail = (message) => { throw new Error(message); }; + +export const isOmittedSourcePath = (sourceRoot, candidate) => { + const relative = path.relative(sourceRoot, candidate); + return relative !== "" && relative.split(path.sep).some((part) => omittedDirectories.has(part)); +}; + +export const inspectPhysicalSpawnfileSource = async (source) => { + const sourceInfo = await lstat(source).catch(() => fail("--source checkout is unavailable")); + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + return fail("--source must be a physical Spawnfile checkout directory"); + } + const physicalPath = await realpath(source); + let manifest; + try { + manifest = JSON.parse(await readFile(path.join(source, "package.json"), "utf8")); + } catch (error) { + return fail(`Unable to read ${path.join(source, "package.json")}: ${error instanceof Error ? error.message : String(error)}`); + } + if (manifest?.name !== "spawnfile" || typeof manifest.version !== "string") { + return fail("--source does not identify a Spawnfile package checkout"); + } + return Object.freeze({ package_version: manifest.version, path: physicalPath }); +}; + +/** Copies source into a private build staging area without changing the checkout. */ +export const stagePhysicalSpawnfileSource = async (source, temporaryRoot) => { + const origin = await inspectPhysicalSpawnfileSource(source); + const staging = path.join(temporaryRoot, "source-stage"); + await mkdir(staging, { mode: 0o700 }); + await cp(source, staging, { + dereference: false, + filter: (candidate) => !isOmittedSourcePath(source, candidate), + preserveTimestamps: false, + recursive: true, + }); + return Object.freeze({ origin, staging }); +}; diff --git a/scripts/spawnfile-source-stage.test.mjs b/scripts/spawnfile-source-stage.test.mjs new file mode 100644 index 0000000..e0e5e3a --- /dev/null +++ b/scripts/spawnfile-source-stage.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { stagePhysicalSpawnfileSource } from "./spawnfile-source-stage.mjs"; + +test("source staging copies a physical checkout without its dependency or runtime state", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-source-stage-")); + const source = path.join(root, "source"); + const temporaryRoot = path.join(root, "temporary"); + await Promise.all([ + mkdir(path.join(source, "node_modules", "ignored"), { recursive: true }), + mkdir(path.join(source, ".spawn", "ignored"), { recursive: true }), + mkdir(path.join(source, "dist"), { recursive: true }), + mkdir(temporaryRoot), + ]); + await Promise.all([ + writeFile(path.join(source, "package.json"), '{"name":"spawnfile","version":"1.2.3"}\n'), + writeFile(path.join(source, "kept.txt"), "kept\n"), + writeFile(path.join(source, "node_modules", "ignored", "state"), "ignored\n"), + writeFile(path.join(source, ".spawn", "ignored", "state"), "ignored\n"), + writeFile(path.join(source, "dist", "generated"), "ignored\n"), + ]); + try { + const staged = await stagePhysicalSpawnfileSource(source, temporaryRoot); + assert.deepEqual(staged.origin, { package_version: "1.2.3", path: await realpath(source) }); + assert.equal(await readFile(path.join(staged.staging, "kept.txt"), "utf8"), "kept\n"); + await assert.rejects(access(path.join(staged.staging, "node_modules"))); + await assert.rejects(access(path.join(staged.staging, ".spawn"))); + await assert.rejects(access(path.join(staged.staging, "dist"))); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 25df710..21f40a2 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -6,18 +6,32 @@ Business logic belongs in `src/schema/` (validate/run) or `src/observe/` (observe); command handlers should only parse arguments, read files, call those modules, and format output. -`recover.ts` is the thin public restart route for durable composed journals. +`index.ts` only dispatches; `cliShared.ts`, `validateCommand.ts`, and +`runCommand.ts` own shared formatting and the validate/run command routes. +`recover.ts` is the thin public restart route for durable composed journals and +reconstructs its provider from the versioned bootstrap capsule. `runArguments.ts` parses the complete run flag matrix before authority opens. `runRoute.ts` resolves authored `spawnfile:` linkage and selects composed or explicit local execution. `composedRunCommand.ts` is the single thin production -adapter into the generic composed supervisor. `composedRunBootstrap.ts` binds a -fixture declaration to public Spawnfile CLI receipts without owning private -target configuration. `compiledOrganizationIdentity.ts` keeps Spawnfile's short +adapter into the generic composed supervisor; `composedRunCompletion.ts` owns +seal/replay/final-receipt handling. `composedRunBootstrap.ts` binds a fixture +declaration to public Spawnfile CLI receipts without owning private target +configuration. The `composedBootstrap*` modules own the durable pre-target +capsule, reconstruction, and one-way execution binding. +`compiledOrganizationIdentity.ts` keeps Spawnfile's short compile fingerprint distinct from the domain-separated composed artifact digest. +`composedProjectPreflight.ts` isolates trusted local binding/source checks and +detects ordinary bootstrap-time source drift. +`composedPreflightReport.ts` writes the preflight Spawnfile compile report once +as a mode-0600 fsynced authority snapshot and verifies its capsule-bound digest +for recovery; the mutable compiled report may be rewritten by `spawnfile up` +and is never a recovery identity source. `credentialBindingProjection.ts` projects logical credential aliases consistently across provisioning, world-member references, and private target mount names. `composedRunArtifacts.ts` reconciles exported owner evidence into the atomic composed run record before sealing. `composedViewerBinding.ts` corroborates the host-only viewer data mapping against trusted project extension ids before reserving the record. + +Changed production files stay at or below 200 lines. diff --git a/src/cli/cliShared.ts b/src/cli/cliShared.ts new file mode 100644 index 0000000..666c021 --- /dev/null +++ b/src/cli/cliShared.ts @@ -0,0 +1,47 @@ +import { ZodError } from "zod"; + +import { + createBindingDiagnostics, + loadSpawnfileReport, + type BindingDiagnostic, + type Simfile, +} from "../schema/index.js"; + +export const simfileCliUsage = (): string => [ + "Usage:", + " simfile validate [--json] [--spawnfile-report |]", + " simfile run [--mode live|lifecycle-replay-smoke] [--view] [--out ] [--seed ] [--run-id ]", + " simfile run --local --ticks [--out ] [--seed ] [--run-id ] [--acts ] [--clock ] [--moltnet-artifact transcript|delivery] [--spawnfile-report |]", + " simfile observe [--json]", + " simfile view --state ", + " simfile view ", + " simfile recover --journal --run-id --authority-digest ", + " simfile view --help", + " simfile --help", + "", +].join("\n"); + +export const formatCliError = (error: unknown): string => error instanceof ZodError + ? error.issues.map((issue) => + `${issue.path.join(".") || ""}: ${issue.message}`).join("\n") + : error instanceof Error ? error.message : String(error); + +export const bindingDiagnostics = async ( + simfile: Simfile, + warnings: string[], + reportSource?: string, +): Promise => [ + ...warnings.map((message): BindingDiagnostic => ({ level: "warn", message })), + ...(reportSource === undefined ? [] + : createBindingDiagnostics(simfile, await loadSpawnfileReport(reportSource))), +]; + +export const hasErrorDiagnostic = (diagnostics: readonly BindingDiagnostic[]): boolean => + diagnostics.some((diagnostic) => diagnostic.level === "error"); + +export const printDiagnostics = (diagnostics: readonly BindingDiagnostic[]): void => { + for (const diagnostic of diagnostics) { + const prefix = diagnostic.level === "error" ? "error" : "warning"; + process.stderr.write(`${prefix}: ${diagnostic.message}\n`); + } +}; diff --git a/src/cli/compiledOrganizationIdentity.ts b/src/cli/compiledOrganizationIdentity.ts index 535d24a..068f60c 100644 --- a/src/cli/compiledOrganizationIdentity.ts +++ b/src/cli/compiledOrganizationIdentity.ts @@ -18,7 +18,7 @@ const compileReportSchema = z.object({ source_revision: z.string().regex(/^[a-f0-9]{40}$/u), version: z.literal("spawnfile.moltnet-release-identity.v1"), }).strict(), - }).passthrough(), + }).passthrough().optional(), runtime_instances: z.array(z.object({ engine_by_node_id: z.record(z.string().min(1), z.string().min(1)), }).passthrough()).min(1), @@ -60,8 +60,9 @@ export const compileReportMoltnetReleaseExpectation = ( asset_sha256: string; release_version: string; source_revision: string; -}> => { - const release = report.container.moltnet.release; +}> | undefined => { + const release = report.container.moltnet?.release; + if (release === undefined) return undefined; return Object.freeze({ architecture: release.architecture, asset_sha256: release.asset_sha256, diff --git a/src/cli/composedBootstrapContract.ts b/src/cli/composedBootstrapContract.ts new file mode 100644 index 0000000..5dfb859 --- /dev/null +++ b/src/cli/composedBootstrapContract.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; + +import { + composedOrganizationExportLifecycleInvocationId, + composedRunIdSchema, +} from "../compose/index.js"; +import { digestComposedJson } from "../compose/json.js"; + +export const sha256 = (bytes: Uint8Array | string): `sha256:${string}` => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +const key = (domain: string, value: unknown): string => + digestComposedJson(domain, value).slice(7, 39); + +export const composedOrganizationContainerName = (runId: string): string => + `simfile-org-${key("simfile.composed-container.v1", runId).slice(0, 16)}`; +export const composedDeploymentName = (runId: string): string => + `simfile-${key("simfile.composed-deployment.v1", runId).slice(0, 16)}`; +export const composedOrganizationUnitId = (runId: string): string => + `${composedDeploymentName(runId)}-container`; +export const composedHandoffRunEnvironment = ( + runId: string, +): Readonly> => + Object.freeze({ NOOPOLIS_RUN_ID: composedRunIdSchema.parse(runId) }); +export const composedProviderLifecycleInvocations = ( + runId: string, + requestDigest: string, +) => Object.freeze({ + down: `lci_${key("simfile.composed-lifecycle.down.v1", runId)}`, + export: composedOrganizationExportLifecycleInvocationId(requestDigest), + up: `lci_${key("simfile.composed-lifecycle.up.v1", runId)}`, +}); + +export const composedIdempotencyKey = ( + domain: string, + value: unknown, +): string => `idem_${key(domain, value)}`; diff --git a/src/cli/composedBootstrapFinalize.ts b/src/cli/composedBootstrapFinalize.ts new file mode 100644 index 0000000..f545ec9 --- /dev/null +++ b/src/cli/composedBootstrapFinalize.ts @@ -0,0 +1,102 @@ +import { + bindComposedJournalExecution, +} from "../compose/index.js"; +import { canonicalComposedJson } from "../compose/json.js"; +import { bootstrapJournaledTarget } from "../spawnfile/targetBootstrap.js"; +import { provisionJournaledCredentials } from + "../spawnfile/journaledCredentialProvisioning.js"; +import { writeOrVerifyPrivateComposedJson } from "./composedProjectPreflight.js"; +import { + composedIdempotencyKey, +} from "./composedBootstrapContract.js"; +import { createComposedCredentialRequest } from "./composedCredentialRequest.js"; +import { createBoundComposedExecution } from "./composedExecutionBinding.js"; +import type { + LinkedComposedBootstrap, + PreparedComposedBootstrap, +} from "./composedBootstrapState.js"; + +export const finalizeComposedBootstrap = async ( + state: PreparedComposedBootstrap, + signal?: AbortSignal, +): Promise => { + const target = await bootstrapJournaledTarget({ + base_image: state.base_image, + context: state.cli, + create_bundle_request: (selected) => ({ + ...state.bundle_request_base, + idempotency_key: composedIdempotencyKey( + "simfile.composed-prepare-bundle.v1", + { bundle_digest: state.request.world.bundle_digest, + run_id: state.request.run_id, selected_target: selected }, + ), + selected_target: { fingerprint: selected.fingerprint, handle: selected.handle }, + }), + docker_command: state.docker_command, + evidence_destination: state.paths.world_evidence_archive, + journal_session: state.journal_session, + local_context: state.request.target.selector, + prepared_plan: state.paths.prepared_plan, + select_request: state.selected_request, + signal, + }); + let succeeded = false; + try { + await writeOrVerifyPrivateComposedJson( + state.paths.selected_target_file, target.selected_target, + ); + const network = state.preparation.bundle.manifest.network; + const credentialRequest = createComposedCredentialRequest({ + authentication: state.authentication, + descriptor_digest: state.request.descriptor_digest, + json_url: `http://${network.dns_alias}:${network.internal_port}/v1/world`, + mcp_url: `http://${network.dns_alias}:${network.internal_port}/mcp`, + projection: state.credential_projection, + run_id: state.request.run_id, + selected_target: target.selected_target, + world_instance_id: state.preparation.readiness_expectation.world_instance_id, + }); + const auth = await provisionJournaledCredentials({ context: state.cli, + env_file: state.paths.env_file, journal_session: state.journal_session, + request: credentialRequest, resolved_grants_file: state.paths.grants_file, + signal, world_bindings_file: state.paths.world_bindings_file }); + const bound = createBoundComposedExecution({ auth, bootstrap: state, + resolution: target.resolution, selected_target: target.selected_target }); + const current = state.journal_session.current(); + if (current.execution === undefined) { + const journal = bindComposedJournalExecution(current, bound.execution, bound.binding); + await state.journal_session.replace(current, journal); + } else if (canonicalComposedJson(current.execution) + !== canonicalComposedJson(bound.execution) + || canonicalComposedJson(current.bootstrap_binding) + !== canonicalComposedJson(bound.binding)) { + throw new TypeError("composed bound execution identity changed during recovery"); + } + if (canonicalComposedJson(state.journal_session.current().bootstrap_binding) + !== canonicalComposedJson(bound.binding)) { + throw new TypeError("composed execution binding was not committed exactly once"); + } + const result = Object.freeze({ + auth, + command_mode: state.command_mode, + compile_fingerprint: state.report.compile_fingerprint, + execution: bound.execution, + journal_path: state.paths.journal, + journal_session: state.journal_session, + organization_evidence_directory: state.paths.organization_evidence, + preparation: state.preparation, + request: state.request, + run_id: state.request.run_id, + run_path: state.paths.run, + source_handles: Object.freeze(auth.credentials.map(({ source_handle }) => source_handle)), + support_root: state.paths.support_root, + target_provider: target.provider, + trusted_project_root: state.capsule.provider.spawnfile_cwd, + world_evidence_directory: state.paths.world_evidence, + }); + succeeded = true; + return result; + } finally { + if (!succeeded) target.provider.close(); + } +}; diff --git a/src/cli/composedBootstrapLocal.ts b/src/cli/composedBootstrapLocal.ts new file mode 100644 index 0000000..ca910c6 --- /dev/null +++ b/src/cli/composedBootstrapLocal.ts @@ -0,0 +1,165 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + createBootstrapComposedPhaseJournal, + createComposedJournalSession, + parseComposedBootstrapCapsule, +} from "../compose/index.js"; +import type { Simfile } from "../schema/index.js"; +import { runSpawnfileCompile } from "../spawnfile/bootstrapCli.js"; +import { runSpawnfileDeriveBundlePolicy } from "../spawnfile/containerBundleCli.js"; +import { resolveSpawnfileOrganizationAuthentication } from + "../spawnfile/organizationAuthentication.js"; +import { runSpawnfileTargetConfigPreview } from "../spawnfile/targetConfigPreview.js"; +import { + assertLinkedSpawnfileSourceUnchanged, + loadComposedProjectBinding, + readLinkedSpawnfileSource, + writePrivateComposedJson, +} from "./composedProjectPreflight.js"; +import { compileReportMemberEngines } from "./compiledOrganizationIdentity.js"; +import { composedIdempotencyKey, composedOrganizationContainerName } from + "./composedBootstrapContract.js"; +import { createComposedBootstrapDirectories, + type ComposedBootstrapPaths } from "./composedBootstrapPaths.js"; +import type { PreparedComposedBootstrap } from "./composedBootstrapState.js"; +import { + type AdmittedComposedSpawnfile, + bindComposedTargetArchitecture, +} from "./composedSpawnfileAdmission.js"; +import { describeComposedProject } from "./composedProjectDescriptor.js"; +import { writePreflightCompileReport } from "./composedPreflightReport.js"; +import type { ComposedCommandMode } from "./runArguments.js"; + +export const prepareLocalComposedBootstrap = async (input: Readonly<{ + admitted: AdmittedComposedSpawnfile; + command_mode: ComposedCommandMode; + environment: NodeJS.ProcessEnv; + paths: ComposedBootstrapPaths; + run_id: string; + seed: string; + signal?: AbortSignal; + simfile: Simfile; + simfile_path: string; + source_text: string; + spawnfile_path: string; + target_context: string; +}>): Promise => { + await createComposedBootstrapDirectories(input.paths); + let admitted = input.admitted; + const baseImage = input.environment.SIMFILE_SPAWNFILE_BASE_IMAGE + ?? "node:22-bookworm-slim"; + const dockerCommand = input.environment.SIMFILE_SPAWNFILE_DOCKER_COMMAND ?? "docker"; + const target = await runSpawnfileTargetConfigPreview({ base_image: baseImage, + context: admitted.context, docker_command: dockerCommand, + evidence_destination: input.paths.world_evidence_archive, + local_context: input.target_context, signal: input.signal }); + admitted = bindComposedTargetArchitecture(admitted, target.platform.architecture); + const spawnfileSource = await readLinkedSpawnfileSource(input.spawnfile_path); + const binding = await loadComposedProjectBinding(input.simfile_path, input.simfile); + const preparation = await binding.prepareComposedProject({ + base_image_config_digest: target.base_image.config_digest, + evidence_root: "/var/lib/simfile/evidence", + internal_port: 4070, + organization_container_name: composedOrganizationContainerName(input.run_id), + platform: target.platform, + run_id: input.run_id, + secret_root: "/run/spawnfile-secrets", + seed: input.seed, + simfile_path: input.simfile_path, + spawnfile_path: input.spawnfile_path, + }); + const bundle = preparation.bundle; + const claims = { archiveDigest: bundle.archive_sha256, + artifactDigest: bundle.manifest.artifact.service_digest, + baseImageConfigDigest: target.base_image.config_digest, + bundleDigest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, + launcherDigest: bundle.manifest.launcher.sha256, + networkAlias: bundle.manifest.network.dns_alias, platform: target.platform }; + const policy = await runSpawnfileDeriveBundlePolicy(admitted.context, claims, input.signal); + const mapping = { archive_digest: bundle.archive_sha256, + artifact_manifest_digest: bundle.manifest.artifact.service_digest, + base_image_config_digest: target.base_image.config_digest, + build_policy_digest: policy.build_policy_digest, bundle_digest: bundle.manifest.digest, + entrypoint: bundle.manifest.entrypoint, launcher_digest: bundle.manifest.launcher.sha256, + network_alias: bundle.manifest.network.dns_alias, platform: target.platform, + platform_digest: policy.platform_digest }; + await writePrivateComposedJson(input.paths.prepared_plan, { + evidence_destination: input.paths.world_evidence_archive, + prepared_artifact_mapping: mapping, + version: "spawnfile.target-config-prepared-plan.v1", + }); + const rawReport = await runSpawnfileCompile(admitted.context, { + compiled_output_directory: input.paths.compiled, + organization_path: input.spawnfile_path, + signal: input.signal, + }); + const snapshot = await writePreflightCompileReport(input.paths.preflight_report, rawReport); + const report = snapshot.report; + if (report.container.moltnet?.release !== undefined + && report.container.moltnet.release.architecture !== target.platform.architecture) { + throw new TypeError("Spawnfile compile target architecture changed"); + } + await assertLinkedSpawnfileSourceUnchanged(spawnfileSource); + if (await readFile(input.simfile_path, "utf8") !== input.source_text) { + throw new TypeError("Simfile source changed during composed bootstrap"); + } + const authentication = resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: input.environment.SPAWNFILE_AUTH_PROFILE, + member_engines: compileReportMemberEngines(report), + }); + const project = describeComposedProject({ + authentication_profile: authentication.correlation_auth_profile, + build_policy_digest: policy.build_policy_digest, + compile_fingerprint: report.compile_fingerprint, + platform_digest: policy.platform_digest, + preparation, run_id: input.run_id, selected_context: input.target_context, + simfile_source: input.source_text, spawnfile_source: spawnfileSource.bytes, target, + }); + await writePrivateComposedJson(input.paths.grants_file, { grants: + project.credential_projection.world_members.map((member) => ({ + capability_manifest: member.capability_manifest, member_id: member.id, + principal_id: member.principal_id })), run_id: input.run_id, + version: "spawnfile.auth.resolved-world-grants.v1", + world_instance_id: preparation.readiness_expectation.world_instance_id }); + const capsule = parseComposedBootstrapCapsule({ command_mode: input.command_mode, + paths: { compiled: input.paths.compiled, env_file: input.paths.env_file, + grants_file: input.paths.grants_file, journal: input.paths.journal, + organization_evidence: input.paths.organization_evidence, + organization_path: input.spawnfile_path, preflight_report: input.paths.preflight_report, + prepared_plan: input.paths.prepared_plan, + run: input.paths.run, selected_target_file: input.paths.selected_target_file, + simfile: input.simfile_path, support_root: input.paths.support_root, + world_bindings_file: input.paths.world_bindings_file, + world_evidence: input.paths.world_evidence, + world_evidence_archive: input.paths.world_evidence_archive }, + project: { compile_fingerprint: report.compile_fingerprint, + descriptor_digest: project.descriptor_digest, + preflight_report_digest: snapshot.digest, seed: input.seed, + simfile_source_digest: project.simfile_source_digest, + spawnfile_source_digest: project.spawnfile_source_digest }, + provider: { base_image: target.base_image.reference, + capability_contract_digest: admitted.capability_contract_digest, + context: input.target_context, docker_command: dockerCommand, + process_environment: admitted.process_environment, + spawnfile_bin: admitted.identity.path, spawnfile_cwd: path.dirname(input.simfile_path), + spawnfile_executable_sha256: admitted.identity.sha256, + spawnfile_package_version: admitted.package_version }, + run_id: input.run_id, version: "simfile.composed-bootstrap-capsule.v2" }); + const journal = createBootstrapComposedPhaseJournal( + project.request, capsule, new Date().toISOString(), + ); + const journalSession = await createComposedJournalSession(input.paths.journal, journal); + return Object.freeze({ authentication, base_image: target.base_image.reference, + bundle_request_base: project.bundle_request_base, capsule, + cli: admitted.context, command_mode: input.command_mode, + credential_projection: project.credential_projection, + docker_command: dockerCommand, journal_session: journalSession, paths: input.paths, + preparation, report, request: project.request, + selected_request: Object.freeze({ idempotency_key: composedIdempotencyKey( + "simfile.composed-select-target.v1", { context: input.target_context, + run_id: input.run_id }), operation: "select_target", + target_reference: input.target_context, + version: "spawnfile.target-resource.request.v1" }) }); +}; diff --git a/src/cli/composedBootstrapPaths.ts b/src/cli/composedBootstrapPaths.ts new file mode 100644 index 0000000..2672d0c --- /dev/null +++ b/src/cli/composedBootstrapPaths.ts @@ -0,0 +1,88 @@ +import { lstat, mkdir } from "node:fs/promises"; +import path from "node:path"; + +import type { ComposedCommandMode } from "./runArguments.js"; + +export interface ComposedBootstrapPaths { + readonly auth: string; + readonly compiled: string; + readonly env_file: string; + readonly grants_file: string; + readonly journal: string; + readonly organization_evidence: string; + readonly preflight_report: string; + readonly prepared_plan: string; + readonly run: string; + readonly selected_target_file: string; + readonly support_root: string; + readonly world_bindings_file: string; + readonly world_evidence: string; + readonly world_evidence_archive: string; +} + +const environmentValue = ( + environment: NodeJS.ProcessEnv, + name: string, +): string | undefined => { + const value = environment[name]; + return value === undefined || value.length === 0 ? undefined : value; +}; + +export const bootstrapOption = environmentValue; + +export const resolveComposedRunIdentity = (input: Readonly<{ + out_dir?: string; + run_id: string; +}>): Readonly<{ run_id: string; run_path: string }> => Object.freeze({ + run_id: input.run_id, + run_path: path.resolve(input.out_dir ?? `runs/${input.run_id}`), +}); + +export const assertComposedRunPathAvailable = async (runPath: string): Promise => { + try { + await lstat(runPath); + throw new TypeError("composed output path already exists"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +}; + +export const createComposedBootstrapPaths = (input: Readonly<{ + environment: NodeJS.ProcessEnv; + run_id: string; + run_path: string; +}>): ComposedBootstrapPaths => { + const supportRoot = path.resolve(environmentValue( + input.environment, "SIMFILE_COMPOSED_SUPPORT_ROOT", + ) ?? path.join(path.dirname(input.run_path), ".simfile-composed", input.run_id)); + return Object.freeze({ + auth: path.join(supportRoot, "auth"), + compiled: path.join(supportRoot, "compiled"), + env_file: path.join(supportRoot, "organization.env"), + grants_file: path.join(supportRoot, "resolved-world-grants.json"), + journal: path.join(supportRoot, "journal", "phase-journal.json"), + organization_evidence: path.join(supportRoot, "evidence", "organization"), + preflight_report: path.join(supportRoot, "preflight-compile-report.json"), + prepared_plan: path.join(supportRoot, "target-plan.json"), + run: input.run_path, + selected_target_file: path.join(supportRoot, "selected-target.json"), + support_root: supportRoot, + world_bindings_file: path.join(supportRoot, "world-bindings.json"), + world_evidence: path.join(supportRoot, "evidence", "world"), + world_evidence_archive: path.join(supportRoot, "evidence", "world.tar"), + }); +}; + +export const createComposedBootstrapDirectories = async ( + paths: ComposedBootstrapPaths, +): Promise => { + await Promise.all([ + mkdir(paths.auth, { recursive: true, mode: 0o700 }), + mkdir(path.dirname(paths.journal), { recursive: true, mode: 0o700 }), + mkdir(path.dirname(paths.organization_evidence), { recursive: true, mode: 0o700 }), + ]); +}; + +export const composedCommandMode = ( + value: ComposedCommandMode | undefined, +): ComposedCommandMode => value ?? "live"; diff --git a/src/cli/composedBootstrapRecoverState.ts b/src/cli/composedBootstrapRecoverState.ts new file mode 100644 index 0000000..c83fd29 --- /dev/null +++ b/src/cli/composedBootstrapRecoverState.ts @@ -0,0 +1,174 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + canonicalComposedJson, +} from "../compose/json.js"; +import type { ComposedBootstrapCapsule, ComposedJournalSession } from "../compose/index.js"; +import { parseSimfileSource } from "../schema/index.js"; +import { runSpawnfileDeriveBundlePolicy } from "../spawnfile/containerBundleCli.js"; +import { resolveSpawnfileOrganizationAuthentication } from + "../spawnfile/organizationAuthentication.js"; +import { + COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS, + type BootstrapSpawnfileCliContext, +} from "../spawnfile/process.js"; +import { runSpawnfileTargetConfigPreview } from "../spawnfile/targetConfigPreview.js"; +import { + loadComposedProjectBinding, + readLinkedSpawnfileSource, +} from "./composedProjectPreflight.js"; +import { + compileReportMemberEngines, +} from "./compiledOrganizationIdentity.js"; +import { composedIdempotencyKey, composedOrganizationContainerName } from + "./composedBootstrapContract.js"; +import type { ComposedBootstrapPaths } from "./composedBootstrapPaths.js"; +import type { PreparedComposedBootstrap } from "./composedBootstrapState.js"; +import { revalidateComposedSpawnfile } from "./composedSpawnfileAdmission.js"; +import { describeComposedProject } from "./composedProjectDescriptor.js"; +import { + assertRecoverySourceDigests, + readPreflightCompileReport, +} from "./composedPreflightReport.js"; + +const child = (root: string, candidate: string): boolean => + candidate.startsWith(`${root}${path.sep}`); + +const capsulePaths = (capsule: ComposedBootstrapCapsule): ComposedBootstrapPaths => { + const values = capsule.paths; + for (const [name, value] of Object.entries(values)) { + if (["organization_path", "run", "simfile", "support_root"].includes(name)) continue; + if (!child(values.support_root, value)) { + throw new TypeError("composed bootstrap capsule path escaped its private root"); + } + } + return Object.freeze({ auth: capsule.provider.process_environment.SPAWNFILE_HOME!, + compiled: values.compiled, env_file: values.env_file, grants_file: values.grants_file, + journal: values.journal, organization_evidence: values.organization_evidence, + preflight_report: values.preflight_report, prepared_plan: values.prepared_plan, run: values.run, + selected_target_file: values.selected_target_file, support_root: values.support_root, + world_bindings_file: values.world_bindings_file, + world_evidence: values.world_evidence, + world_evidence_archive: values.world_evidence_archive }); +}; + +export const reconstructComposedBootstrap = async (input: Readonly<{ + capsule: ComposedBootstrapCapsule; + journal_session: ComposedJournalSession; + signal?: AbortSignal; +}>): Promise => { + const capsule = input.capsule; + const paths = capsulePaths(capsule); + if (paths.journal !== input.journal_session.path + || capsule.provider.spawnfile_cwd !== path.dirname(capsule.paths.simfile) + || capsule.provider.process_environment.NOOPOLIS_RUN_ID !== capsule.run_id + || capsule.provider.process_environment.SPAWNFILE_HOME !== paths.auth) { + throw new TypeError("composed bootstrap capsule identity is contradictory"); + } + const support = await lstat(paths.support_root); + if (!support.isDirectory() || support.isSymbolicLink() + || process.getuid?.() !== undefined && support.uid !== process.getuid!() + || process.platform !== "win32" && (support.mode & 0o777) !== 0o700) { + throw new TypeError("composed bootstrap support root changed"); + } + const identity = { path: capsule.provider.spawnfile_bin, + sha256: capsule.provider.spawnfile_executable_sha256 as `sha256:${string}` }; + const cli: BootstrapSpawnfileCliContext = { + bootstrapLocalExecutableIdentity: identity, + cwd: capsule.provider.spawnfile_cwd, + env: { ...process.env, ...capsule.provider.process_environment }, + spawnfileBin: capsule.provider.spawnfile_bin, + timeoutMs: COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS, + }; + await revalidateComposedSpawnfile({ + capability_contract_digest: capsule.provider.capability_contract_digest, + context: cli, + signal: input.signal, + }); + const sourceText = await readFile(capsule.paths.simfile, "utf8"); + const parsed = parseSimfileSource(sourceText, { path: capsule.paths.simfile }); + const spawnfileSource = await readLinkedSpawnfileSource(capsule.paths.organization_path); + assertRecoverySourceDigests({ + expected_simfile_digest: capsule.project.simfile_source_digest, + expected_spawnfile_digest: capsule.project.spawnfile_source_digest, + simfile_source: sourceText, + spawnfile_source: spawnfileSource.bytes, + }); + const target = await runSpawnfileTargetConfigPreview({ + base_image: capsule.provider.base_image, + context: cli, + docker_command: capsule.provider.docker_command, + evidence_destination: paths.world_evidence_archive, + local_context: capsule.provider.context, + signal: input.signal, + }); + const binding = await loadComposedProjectBinding(capsule.paths.simfile, parsed.simfile); + const preparation = await binding.prepareComposedProject({ + base_image_config_digest: target.base_image.config_digest, + evidence_root: "/var/lib/simfile/evidence", + internal_port: 4070, + organization_container_name: composedOrganizationContainerName(capsule.run_id), + platform: target.platform, + run_id: capsule.run_id, + secret_root: "/run/spawnfile-secrets", + seed: capsule.project.seed, + simfile_path: capsule.paths.simfile, + spawnfile_path: capsule.paths.organization_path, + }); + const bundle = preparation.bundle; + const claims = { archiveDigest: bundle.archive_sha256, + artifactDigest: bundle.manifest.artifact.service_digest, + baseImageConfigDigest: target.base_image.config_digest, + bundleDigest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, + launcherDigest: bundle.manifest.launcher.sha256, + networkAlias: bundle.manifest.network.dns_alias, platform: target.platform }; + const policy = await runSpawnfileDeriveBundlePolicy(cli, claims, input.signal); + const mapping = { archive_digest: bundle.archive_sha256, + artifact_manifest_digest: bundle.manifest.artifact.service_digest, + base_image_config_digest: target.base_image.config_digest, + build_policy_digest: policy.build_policy_digest, bundle_digest: bundle.manifest.digest, + entrypoint: bundle.manifest.entrypoint, launcher_digest: bundle.manifest.launcher.sha256, + network_alias: bundle.manifest.network.dns_alias, platform: target.platform, + platform_digest: policy.platform_digest }; + const expectedPlan = { evidence_destination: paths.world_evidence_archive, + prepared_artifact_mapping: mapping, + version: "spawnfile.target-config-prepared-plan.v1" }; + if (canonicalComposedJson(JSON.parse(await readFile(paths.prepared_plan, "utf8"))) + !== canonicalComposedJson(expectedPlan)) { + throw new TypeError("composed prepared target plan changed"); + } + const report = await readPreflightCompileReport( + paths.preflight_report, capsule.project.preflight_report_digest, + ); + const authentication = resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: input.journal_session.current().request.target.auth_profile, + member_engines: compileReportMemberEngines(report), + }); + const project = describeComposedProject({ + authentication_profile: authentication.correlation_auth_profile, + build_policy_digest: policy.build_policy_digest, + compile_fingerprint: report.compile_fingerprint, + platform_digest: policy.platform_digest, preparation, run_id: capsule.run_id, + selected_context: capsule.provider.context, simfile_source: sourceText, + spawnfile_source: spawnfileSource.bytes, target, + }); + const journal = input.journal_session.current(); + if (canonicalComposedJson(project.request) !== canonicalComposedJson(journal.request) + || report.compile_fingerprint !== capsule.project.compile_fingerprint + || project.descriptor_digest !== capsule.project.descriptor_digest + || project.simfile_source_digest !== capsule.project.simfile_source_digest + || project.spawnfile_source_digest !== capsule.project.spawnfile_source_digest) { + throw new TypeError("composed bootstrap project identity changed"); + } + return Object.freeze({ authentication, base_image: capsule.provider.base_image, + bundle_request_base: project.bundle_request_base, capsule, cli, + command_mode: capsule.command_mode, credential_projection: project.credential_projection, + docker_command: capsule.provider.docker_command, + journal_session: input.journal_session, paths, preparation, report, + request: project.request, selected_request: Object.freeze({ idempotency_key: + composedIdempotencyKey("simfile.composed-select-target.v1", { + context: capsule.provider.context, run_id: capsule.run_id }), + operation: "select_target", target_reference: capsule.provider.context, + version: "spawnfile.target-resource.request.v1" }) }); +}; diff --git a/src/cli/composedBootstrapRecovery.ts b/src/cli/composedBootstrapRecovery.ts new file mode 100644 index 0000000..c0d7e5e --- /dev/null +++ b/src/cli/composedBootstrapRecovery.ts @@ -0,0 +1,43 @@ +import { + composedRecoveryCommand, + createComposedRecoveryReceipt, + markComposedJournalRecoverable, + type ComposedJournalSession, + type ComposedRecoveryReceipt, +} from "../compose/index.js"; + +export class ComposedBootstrapRecoveryError extends Error { + readonly cause: unknown; + readonly receipt: ComposedRecoveryReceipt; + + constructor(cause: unknown, receipt: ComposedRecoveryReceipt) { + super("composed bootstrap requires recovery"); + this.name = "ComposedBootstrapRecoveryError"; + this.cause = cause; + this.receipt = receipt; + } +} + +export const preserveComposedBootstrapFailure = async ( + session: ComposedJournalSession, + cause: unknown, +): Promise => { + const current = session.current(); + const recoverable = current.state === "recoverable" ? current + : markComposedJournalRecoverable(current, { + recovery_command: composedRecoveryCommand( + session.path, current.request.run_id, current.authority_digest, + ), + signal: "failure", + }); + if (recoverable !== current) await session.replace(current, recoverable); + return new ComposedBootstrapRecoveryError(cause, createComposedRecoveryReceipt({ + authority_digest: recoverable.authority_digest, + journal_digest: recoverable.journal_digest, + journal_path: session.path, + next_phase: recoverable.interruption!.next_phase, + preserved_evidence: false, + run_id: recoverable.request.run_id, + signal: "failure", + })); +}; diff --git a/src/cli/composedBootstrapState.ts b/src/cli/composedBootstrapState.ts new file mode 100644 index 0000000..ea589a0 --- /dev/null +++ b/src/cli/composedBootstrapState.ts @@ -0,0 +1,55 @@ +import type { + ComposedBootstrapCapsule, + ComposedExecution, + ComposedJournalSession, + ComposedProjectPreparation, + ComposedRunRequest, +} from "../compose/index.js"; +import type { SpawnfileCompileReport } from "./compiledOrganizationIdentity.js"; +import type { SpawnfileBundleRequest } from "../spawnfile/containerBundleCli.js"; +import type { SpawnfileOrganizationAuthentication } from + "../spawnfile/organizationAuthentication.js"; +import type { BootstrapSpawnfileCliContext } from "../spawnfile/process.js"; +import type { ComposedBootstrapPaths } from "./composedBootstrapPaths.js"; +import type { ComposedCommandMode } from "./runArguments.js"; +import type { ComposedCredentialProjection } from "./composedProjectDescriptor.js"; +import type { SpawnfileCredentialProvisioningReceipt } from "../spawnfile/bootstrapCli.js"; +import type { CliComposedTargetProvider } from "../spawnfile/composedTargetProvider.js"; + +export interface PreparedComposedBootstrap { + readonly authentication: SpawnfileOrganizationAuthentication; + readonly base_image: string; + readonly bundle_request_base: Omit; + readonly capsule: ComposedBootstrapCapsule; + readonly cli: BootstrapSpawnfileCliContext; + readonly command_mode: ComposedCommandMode; + readonly credential_projection: ComposedCredentialProjection; + readonly docker_command: string; + readonly journal_session: ComposedJournalSession; + readonly paths: ComposedBootstrapPaths; + readonly preparation: ComposedProjectPreparation; + readonly report: SpawnfileCompileReport; + readonly request: ComposedRunRequest; + readonly selected_request: Readonly>; + readonly source_handles?: readonly string[]; +} + +export interface LinkedComposedBootstrap { + readonly auth: SpawnfileCredentialProvisioningReceipt; + readonly command_mode: ComposedCommandMode; + readonly compile_fingerprint: string; + readonly execution: ComposedExecution; + readonly journal_path: string; + readonly journal_session: ComposedJournalSession; + readonly organization_evidence_directory: string; + readonly preparation: ComposedProjectPreparation; + readonly request: ComposedRunRequest; + readonly run_id: string; + readonly run_path: string; + readonly source_handles: readonly string[]; + readonly support_root: string; + readonly target_provider: CliComposedTargetProvider; + readonly trusted_project_root: string; + readonly world_evidence_directory: string; +} diff --git a/src/cli/composedCredentialRequest.ts b/src/cli/composedCredentialRequest.ts new file mode 100644 index 0000000..0211ff1 --- /dev/null +++ b/src/cli/composedCredentialRequest.ts @@ -0,0 +1,53 @@ +import type { SpawnfileCredentialProvisioningReceipt } from + "../spawnfile/bootstrapCli.js"; +import type { SpawnfileOrganizationAuthentication } from + "../spawnfile/organizationAuthentication.js"; +import type { SpawnfileSelectedTarget } from "../spawnfile/targetSelection.js"; +import type { ComposedCredentialProjection } from "./composedProjectDescriptor.js"; + +export const createComposedCredentialRequest = (input: Readonly<{ + authentication: SpawnfileOrganizationAuthentication; + descriptor_digest: string; + json_url: string; + mcp_url: string; + projection: ComposedCredentialProjection; + run_id: string; + selected_target: SpawnfileSelectedTarget; + world_instance_id: string; +}>): Readonly> => Object.freeze({ + credentials: input.projection.credentials, + descriptor_digest: input.descriptor_digest, + ...(input.authentication.kind === "model" + && input.authentication.model_engine_auth !== undefined + ? { model_engine_auth: input.authentication.model_engine_auth } : {}), + run_id: input.run_id, + scope: "world", + selected_target: input.selected_target, + version: "spawnfile.auth.credential-provisioning.request.v1", + world_bindings: { + json_url: input.json_url, + mcp_url: input.mcp_url, + members: input.projection.world_members.map( + ({ id, principal_id, token_credential_name }) => + ({ id, principal_id, token_credential_name }), + ), + world_instance_id: input.world_instance_id, + }, +}); + +export const bindComposedSecretSources = (input: Readonly<{ + projection: ComposedCredentialProjection; + receipt: SpawnfileCredentialProvisioningReceipt; +}>): readonly Readonly<{ name: string; scope: string; source_handle: string }>[] => { + const sourceByName = new Map(input.receipt.credentials.map( + ({ name, source_handle }) => [name, source_handle], + )); + return Object.freeze(input.projection.secret_bindings.map((binding) => { + const source = sourceByName.get(binding.credential_name); + if (source === undefined) { + throw new TypeError("Spawnfile credential receipt omitted a composed secret binding"); + } + return Object.freeze({ name: binding.name, scope: binding.scope, + source_handle: source }); + })); +}; diff --git a/src/cli/composedExecutionBinding.ts b/src/cli/composedExecutionBinding.ts new file mode 100644 index 0000000..7233647 --- /dev/null +++ b/src/cli/composedExecutionBinding.ts @@ -0,0 +1,108 @@ +import { + COMPOSED_EXECUTION_VERSION, + composedBootstrapDigest, + createComposedBootstrapBinding, + createComposedRunRequestDigest, + parseComposedExecution, + type ComposedExecution, +} from "../compose/index.js"; +import { canonicalComposedJson, digestComposedJson } from "../compose/json.js"; +import type { SpawnfileCredentialProvisioningReceipt } from + "../spawnfile/bootstrapCli.js"; +import type { SpawnfileTargetConfigResolution } from + "../spawnfile/targetConfigResolution.js"; +import type { SpawnfileSelectedTarget } from "../spawnfile/targetSelection.js"; +import { compileReportMemberEngines, + compileReportMoltnetReleaseExpectation } from "./compiledOrganizationIdentity.js"; +import { + composedDeploymentName, + composedOrganizationContainerName, + composedOrganizationUnitId, + composedProviderLifecycleInvocations, + sha256, +} from "./composedBootstrapContract.js"; +import type { PreparedComposedBootstrap } from "./composedBootstrapState.js"; +import { bindComposedSecretSources } from "./composedCredentialRequest.js"; + +export const createBoundComposedExecution = (input: Readonly<{ + auth: SpawnfileCredentialProvisioningReceipt; + bootstrap: PreparedComposedBootstrap; + resolution: SpawnfileTargetConfigResolution["identity"]; + selected_target: SpawnfileSelectedTarget; +}>): Readonly<{ + binding: ReturnType; + execution: ComposedExecution; +}> => { + const state = input.bootstrap; + if (input.auth.world_bindings_digest + !== state.request.organization.world_bindings_digest) { + throw new TypeError("Spawnfile world-binding artifact changed after credential provisioning"); + } + const selectedDigest = sha256(canonicalComposedJson(input.selected_target)); + const requestDigest = createComposedRunRequestDigest(state.request); + const execution = parseComposedExecution({ + configuration: { + organization_expectation: { + deployment_name: composedDeploymentName(state.request.run_id), + member_engines: compileReportMemberEngines(state.report), + ...(compileReportMoltnetReleaseExpectation(state.report) === undefined ? {} : { + moltnet_release: compileReportMoltnetReleaseExpectation(state.report), + }), + selected_target_receipt_digest: selectedDigest, + unit_id: composedOrganizationUnitId(state.request.run_id), + world_binding_digest: input.auth.world_bindings_digest, + }, + readiness_expectation: state.preparation.readiness_expectation, + terminal_tick: state.preparation.terminal_tick, + topology_expectation: { selected_target: { + fingerprint: input.selected_target.fingerprint, + handle: input.selected_target.handle, + } }, + }, + provider: { + compiled_output_directory: state.paths.compiled, + evidence_destination_directory: state.paths.organization_evidence, + evidence_mount_path: "/var/lib/simfile/evidence", + lifecycle_invocations: composedProviderLifecycleInvocations( + state.request.run_id, requestDigest, + ), + organization_handoff: { env_file: state.paths.env_file, + selected_target_receipt_file: state.paths.selected_target_file, + world_bindings_file: state.paths.world_bindings_file }, + organization_container_name: composedOrganizationContainerName(state.request.run_id), + organization_image_tag: `simfile-org-${requestDigest.slice(7, 23)}:run`, + organization_path: state.capsule.paths.organization_path, + process_environment: state.capsule.provider.process_environment, + spawnfile_bin: state.capsule.provider.spawnfile_bin, + spawnfile_capability_contract_digest: + state.capsule.provider.capability_contract_digest, + spawnfile_cwd: state.capsule.provider.spawnfile_cwd, + spawnfile_executable_sha256: state.capsule.provider.spawnfile_executable_sha256, + spawnfile_package_version: state.capsule.provider.spawnfile_package_version, + target_resolution: input.resolution, + terminal_artifact: { id: "composed_terminal", max_bytes: 131_072, + path: "/tmp/spawnfile-public/composed-terminal.json" }, + world_evidence_export: { archive_path: state.paths.world_evidence_archive, + destination_directory: state.paths.world_evidence }, + world_readiness_port: state.preparation.bundle.manifest.network.internal_port, + }, + secret_bindings: bindComposedSecretSources({ projection: state.credential_projection, + receipt: input.auth }), + version: COMPOSED_EXECUTION_VERSION, + }); + const journal = state.journal_session.current(); + const binding = createComposedBootstrapBinding({ + bootstrap_authority_digest: journal.authority_digest, + bootstrap_digest: composedBootstrapDigest(state.capsule), + execution_digest: digestComposedJson(COMPOSED_EXECUTION_VERSION, execution), + request_digest: journal.request_digest, + run_id: state.request.run_id, + target: { context: input.resolution.context, + prepared_evidence_helper: input.resolution.prepared_evidence_helper, + selected_target: { fingerprint: input.selected_target.fingerprint, + handle: input.selected_target.handle }, + selected_target_receipt_digest: selectedDigest, + target_config_digest: input.resolution.target_config_digest }, + }); + return Object.freeze({ binding, execution }); +}; diff --git a/src/cli/composedFailureCleanup.test.ts b/src/cli/composedFailureCleanup.test.ts new file mode 100644 index 0000000..9dc4882 --- /dev/null +++ b/src/cli/composedFailureCleanup.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + runComposedFailureCleanup, + throwAfterComposedFailureCleanup, +} from "./composedFailureCleanup.js"; + +test("composed failure cleanup attempts every step and aggregates failures", async () => { + const calls: string[] = []; + const primary = new Error("primary failure"); + await assert.rejects(throwAfterComposedFailureCleanup(primary, [ + { label: "first", run: async () => { calls.push("first"); throw new Error("one"); } }, + { label: "second", run: async () => { calls.push("second"); } }, + { label: "third", run: async () => { calls.push("third"); throw new Error("three"); } }, + ]), (error) => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors[0], primary); + assert.equal(error.errors.length, 3); + return true; + }); + assert.deepEqual(calls, ["first", "second", "third"]); +}); + +test("successful cleanup rethrows the original failure by identity", async () => { + const primary = new Error("primary failure"); + await assert.rejects(throwAfterComposedFailureCleanup(primary, [ + { label: "only", run: async () => undefined }, + ]), (error) => error === primary); + assert.deepEqual(await runComposedFailureCleanup([]), []); +}); diff --git a/src/cli/composedFailureCleanup.ts b/src/cli/composedFailureCleanup.ts new file mode 100644 index 0000000..d1bde21 --- /dev/null +++ b/src/cli/composedFailureCleanup.ts @@ -0,0 +1,33 @@ +export interface ComposedFailureCleanupStep { + readonly label: string; + run(): Promise; +} + +/** Runs every cleanup step in order and retains every failure. */ +export const runComposedFailureCleanup = async ( + steps: readonly ComposedFailureCleanupStep[], +): Promise => { + const failures: unknown[] = []; + for (const step of steps) { + try { await step.run(); } + catch (error) { + failures.push(new Error(`composed cleanup failed: ${step.label}`, { cause: error })); + } + } + return Object.freeze(failures); +}; + +/** Preserves the primary failure while guaranteeing every cleanup was attempted. */ +export const throwAfterComposedFailureCleanup = async ( + primary: unknown, + steps: readonly ComposedFailureCleanupStep[], +): Promise => { + const failures = await runComposedFailureCleanup(steps); + if (failures.length > 0) { + throw new AggregateError( + [primary, ...failures], + "composed operation failed and cleanup is incomplete", + ); + } + throw primary; +}; diff --git a/src/cli/composedPreflightReport.test.ts b/src/cli/composedPreflightReport.test.ts new file mode 100644 index 0000000..140f6df --- /dev/null +++ b/src/cli/composedPreflightReport.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + assertRecoverySourceDigests, + readPreflightCompileReport, + writePreflightCompileReport, +} from "./composedPreflightReport.js"; + +const report = (fingerprint: string) => ({ + compile_fingerprint: fingerprint, + container: { + runtime_instances: [{ engine_by_node_id: { "agent:analyst": "scripted" } }], + }, +}); +const digest = (bytes: string | Uint8Array): string => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +test("recovery reads the immutable preflight report after up mutates compiled output", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-preflight-report-")); + try { + const snapshotPath = path.join(root, "preflight-report.json"); + const compiledPath = path.join(root, "compiled", "spawnfile-report.json"); + await mkdir(path.dirname(compiledPath)); + const snapshot = await writePreflightCompileReport( + snapshotPath, report("sf1:aaaaaaaaaaaa"), + ); + assert.equal((await stat(snapshotPath)).mode & 0o777, 0o600); + + // Models Spawnfile's legitimate bound recompile during `up`. + await writeFile(compiledPath, JSON.stringify(report("sf1:bbbbbbbbbbbb"))); + const recovered = await readPreflightCompileReport(snapshotPath, snapshot.digest); + assert.equal(recovered.compile_fingerprint, "sf1:aaaaaaaaaaaa"); + assert.equal(JSON.parse(await readFile(compiledPath, "utf8")).compile_fingerprint, + "sf1:bbbbbbbbbbbb"); + } finally { await rm(root, { force: true, recursive: true }); } +}); + +test("recovery fails closed on snapshot or source drift", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-preflight-drift-")); + try { + const snapshotPath = path.join(root, "preflight-report.json"); + const snapshot = await writePreflightCompileReport( + snapshotPath, report("sf1:aaaaaaaaaaaa"), + ); + await writeFile(snapshotPath, JSON.stringify(report("sf1:bbbbbbbbbbbb")), { + mode: 0o600, + }); + await assert.rejects(readPreflightCompileReport(snapshotPath, snapshot.digest), + /snapshot changed/u); + + const simfileSource = "simfile_version: '0.1'\nname: stable\n"; + const spawnfileSource = new TextEncoder().encode("spawnfile_version: '0.1'\n"); + assert.doesNotThrow(() => assertRecoverySourceDigests({ + expected_simfile_digest: digest(simfileSource), + expected_spawnfile_digest: digest(spawnfileSource), + simfile_source: simfileSource, + spawnfile_source: spawnfileSource, + })); + assert.throws(() => assertRecoverySourceDigests({ + expected_simfile_digest: digest(simfileSource), + expected_spawnfile_digest: digest(spawnfileSource), + simfile_source: `${simfileSource}clock: { seed: hostile }\n`, + spawnfile_source: spawnfileSource, + }), /project source changed/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/cli/composedPreflightReport.ts b/src/cli/composedPreflightReport.ts new file mode 100644 index 0000000..40f32af --- /dev/null +++ b/src/cli/composedPreflightReport.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { lstat, open, readFile } from "node:fs/promises"; + +import { canonicalComposedJson } from "../compose/json.js"; +import { + parseSpawnfileCompileReport, + type SpawnfileCompileReport, +} from "./compiledOrganizationIdentity.js"; + +const sha256 = (bytes: string | Uint8Array): `sha256:${string}` => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +const assertPrivateRegularFile = async (target: string): Promise => { + const metadata = await lstat(target); + if (!metadata.isFile() || metadata.isSymbolicLink() + || process.getuid?.() !== undefined && metadata.uid !== process.getuid!() + || process.platform !== "win32" && (metadata.mode & 0o777) !== 0o600) { + throw new TypeError("composed preflight compile report snapshot is unsafe"); + } +}; + +/** Creates the immutable, private authority copy before `spawnfile up` can rewrite its output. */ +export const writePreflightCompileReport = async ( + target: string, + raw: unknown, +): Promise> => { + const report = parseSpawnfileCompileReport(raw); + const bytes = canonicalComposedJson(raw); + const file = await open(target, "wx", 0o600); + try { await file.writeFile(bytes, "utf8"); await file.sync(); } + finally { await file.close(); } + await assertPrivateRegularFile(target); + return Object.freeze({ digest: sha256(bytes), report }); +}; + +/** Reads only the authority snapshot; the mutable compiled report is intentionally irrelevant. */ +export const readPreflightCompileReport = async ( + target: string, + expectedDigest: string, +): Promise => { + await assertPrivateRegularFile(target); + const bytes = await readFile(target); + if (sha256(bytes) !== expectedDigest) { + throw new TypeError("composed preflight compile report snapshot changed"); + } + try { return parseSpawnfileCompileReport(JSON.parse(bytes.toString("utf8")) as unknown); } + catch (error) { + if (error instanceof TypeError) throw error; + throw new TypeError("composed preflight compile report snapshot is invalid"); + } +}; + +export const assertRecoverySourceDigests = (input: Readonly<{ + expected_simfile_digest: string; + expected_spawnfile_digest: string; + simfile_source: string; + spawnfile_source: Uint8Array; +}>): void => { + if (sha256(input.simfile_source) !== input.expected_simfile_digest + || sha256(input.spawnfile_source) !== input.expected_spawnfile_digest) { + throw new TypeError("composed bootstrap project source changed"); + } +}; diff --git a/src/cli/composedProjectDescriptor.ts b/src/cli/composedProjectDescriptor.ts new file mode 100644 index 0000000..4c33112 --- /dev/null +++ b/src/cli/composedProjectDescriptor.ts @@ -0,0 +1,104 @@ +import { Buffer } from "node:buffer"; + +import { + parseComposedRunRequest, + type ComposedProjectPreparation, + type ComposedRunRequest, +} from "../compose/index.js"; +import { digestComposedJson } from "../compose/json.js"; +import type { SpawnfileBundleRequest } from "../spawnfile/containerBundleCli.js"; +import type { SpawnfileTargetConfigPreview } from "../spawnfile/targetConfigPreview.js"; +import { RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS } from "../world-artifact/index.js"; +import { deriveCompiledOrganizationArtifactDigest } from + "./compiledOrganizationIdentity.js"; +import { createComposedWorldBindings } from "./composedWorldBindings.js"; +import { projectCredentialBindingNames } from "./credentialBindingProjection.js"; +import { sha256 } from "./composedBootstrapContract.js"; + +export type ComposedCredentialProjection = Readonly<{ + credentials: ComposedProjectPreparation["credentials"]; + secret_bindings: ComposedProjectPreparation["secret_bindings"]; + world_members: ComposedProjectPreparation["world_members"]; +}>; + +export const describeComposedProject = (input: Readonly<{ + authentication_profile: string; + build_policy_digest: string; + compile_fingerprint: string; + platform_digest: string; + preparation: ComposedProjectPreparation; + run_id: string; + selected_context: string; + simfile_source: string; + spawnfile_source: Uint8Array; + target: SpawnfileTargetConfigPreview; +}>): Readonly<{ + bundle_request_base: Omit; + credential_projection: ComposedCredentialProjection; + descriptor_digest: `sha256:${string}`; + request: ComposedRunRequest; + simfile_source_digest: `sha256:${string}`; + spawnfile_source_digest: `sha256:${string}`; + world_bindings_digest: `sha256:${string}`; +}> => { + const bundle = input.preparation.bundle; + const credentialProjection = projectCredentialBindingNames(input.preparation); + const environmentByName = new Map(credentialProjection.credentials.map( + (credential) => [credential.name, credential.env], + )); + const jsonUrl = `http://${bundle.manifest.network.dns_alias}:` + + `${bundle.manifest.network.internal_port}/v1/world`; + const mcpUrl = `http://${bundle.manifest.network.dns_alias}:` + + `${bundle.manifest.network.internal_port}/mcp`; + const predicted = createComposedWorldBindings({ json_url: jsonUrl, mcp_url: mcpUrl, + members: credentialProjection.world_members.map((member) => ({ + capability_manifest: member.capability_manifest, + id: member.id, + principal_id: member.principal_id, + token_env: environmentByName.get(member.token_credential_name) ?? "", + })), run_id: input.run_id, + world_instance_id: input.preparation.readiness_expectation.world_instance_id }); + const simfileDigest = sha256(input.simfile_source); + const spawnfileDigest = sha256(input.spawnfile_source); + const descriptorDigest = digestComposedJson("simfile.composed-project-descriptor.v1", { + organization: input.compile_fingerprint, + simfile: simfileDigest, + spawnfile: spawnfileDigest, + world: bundle.manifest.digest, + }); + const request = parseComposedRunRequest({ descriptor_digest: descriptorDigest, mode: "live", + organization: { + artifact_digest: deriveCompiledOrganizationArtifactDigest(input.compile_fingerprint), + source_digest: spawnfileDigest, + world_bindings_digest: predicted.digest, + }, required_world_capabilities: ["simfile.world-decision-claim.v1"], + run_id: input.run_id, source_digest: simfileDigest, + target: { auth_profile: input.authentication_profile, + selector: input.selected_context }, + version: "simfile.composed-run-request.v1", + world: { artifact_manifest_digest: bundle.manifest.artifact.service_digest, + bundle_digest: bundle.manifest.digest, + runtime_abi: "simfile.world-sidecar-runtime.v1" } }); + return Object.freeze({ + bundle_request_base: Object.freeze({ + archive_base64: Buffer.from(bundle.archive_bytes).toString("base64"), + archive_digest: bundle.archive_sha256, + archive_entries: [...RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS], + artifact_digest: bundle.manifest.artifact.service_digest, + build_policy_digest: input.build_policy_digest, + bundle_digest: bundle.manifest.digest, + entrypoint: bundle.manifest.entrypoint, + launcher_digest: bundle.manifest.launcher.sha256, + network_alias: bundle.manifest.network.dns_alias, + platform: input.target.platform, + platform_digest: input.platform_digest, + version: "spawnfile.target-local-container-bundle.prepare-request.v1", + }), + credential_projection: credentialProjection, + descriptor_digest: descriptorDigest, + request, + simfile_source_digest: simfileDigest, + spawnfile_source_digest: spawnfileDigest, + world_bindings_digest: predicted.digest, + }); +}; diff --git a/src/cli/composedProjectPreflight.test.ts b/src/cli/composedProjectPreflight.test.ts new file mode 100644 index 0000000..1ebe51c --- /dev/null +++ b/src/cli/composedProjectPreflight.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + assertLinkedSpawnfileSourceUnchanged, + readLinkedSpawnfileSource, + withUnchangedLinkedSpawnfileSource, +} from "./composedProjectPreflight.js"; + +test("linked Spawnfile preflight retains bytes and detects ordinary source drift through symlinks", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-source-preflight-")); + try { + const source = path.join(root, "Spawnfile"); + const linked = path.join(root, "Spawnfile-link"); + await writeFile(source, "organization: one\n"); + await symlink("Spawnfile", linked); + const retained = await readLinkedSpawnfileSource(linked); + await assertLinkedSpawnfileSourceUnchanged(retained); + await writeFile(source, "organization: two\n"); + await assert.rejects(assertLinkedSpawnfileSourceUnchanged(retained), + /linked Spawnfile source changed during composed bootstrap/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); + +test("linked Spawnfile source guard detects drift from preparation and compile operations", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-source-operation-")); + try { + const source = path.join(root, "Spawnfile"); + await writeFile(source, "organization: one\n"); + const preparationSource = await readLinkedSpawnfileSource(source); + await assert.rejects(withUnchangedLinkedSpawnfileSource(preparationSource, async () => { + await writeFile(source, "organization: preparation\n"); + }), /linked Spawnfile source changed during composed bootstrap/u); + + const compileSource = await readLinkedSpawnfileSource(source); + await assert.rejects(withUnchangedLinkedSpawnfileSource(compileSource, async () => { + await writeFile(source, "organization: compile\n"); + throw new Error("compile failed"); + }), /linked Spawnfile source changed during composed bootstrap/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/cli/composedProjectPreflight.ts b/src/cli/composedProjectPreflight.ts new file mode 100644 index 0000000..0b42800 --- /dev/null +++ b/src/cli/composedProjectPreflight.ts @@ -0,0 +1,91 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + createComposedProjectBinding, + type ComposedProjectBinding, +} from "../compose/index.js"; +import { canonicalComposedJson } from "../compose/json.js"; +import type { Simfile } from "../schema/index.js"; + +export interface LinkedSpawnfileSource { + readonly bytes: Uint8Array; + readonly path: string; +} + +const sourceError = (): TypeError => + new TypeError("linked Spawnfile source must be a readable regular file"); + +/** Reads trusted local source bytes; later checks detect ordinary bootstrap drift. */ +export const readLinkedSpawnfileSource = async (spawnfilePath: string): Promise => { + try { + if (!(await stat(spawnfilePath)).isFile()) throw sourceError(); + return Object.freeze({ bytes: await readFile(spawnfilePath), path: spawnfilePath }); + } catch (error) { + if (error instanceof TypeError) throw error; + throw sourceError(); + } +}; + +/** Detects deterministic source replacement, not the final filesystem syscall race. */ +export const assertLinkedSpawnfileSourceUnchanged = async (source: LinkedSpawnfileSource): Promise => { + const current = await readLinkedSpawnfileSource(source.path); + if (current.bytes.byteLength !== source.bytes.byteLength + || !Buffer.from(current.bytes).equals(Buffer.from(source.bytes))) { + throw new TypeError("linked Spawnfile source changed during composed bootstrap"); + } +}; + +/** Runs one bootstrap action while detecting ordinary linked-source drift before and after it. */ +export const withUnchangedLinkedSpawnfileSource = async ( + source: LinkedSpawnfileSource, + operation: () => Promise, +): Promise => { + await assertLinkedSpawnfileSourceUnchanged(source); + try { return await operation(); } + finally { await assertLinkedSpawnfileSourceUnchanged(source); } +}; + +/** Loads trusted local code and applies the public wrapper's one validation pass. */ +export const loadComposedProjectBinding = async ( + simfilePath: string, + simfile: Simfile, +): Promise => { + const reference = simfile.world_sidecar?.binding; + if (reference === undefined) throw new TypeError("linked composed project has no binding"); + const modulePath = path.resolve(path.dirname(simfilePath), reference); + const loaded = await import(pathToFileURL(modulePath).href) as Record; + const binding = loaded.composedProjectBinding as Partial | undefined; + if (binding?.version !== "simfile.composed-project-binding.v1" + || typeof binding.prepareComposedProject !== "function") { + throw new TypeError("linked composed project binding is invalid"); + } + return createComposedProjectBinding({ + prepareComposedProject: async (input) => binding.prepareComposedProject!(input), + }); +}; + +export const writePrivateComposedJson = async (target: string, value: unknown): Promise => { + await writeFile(target, canonicalComposedJson(value), { flag: "wx", mode: 0o600 }); +}; + +/** Creates a private artifact once, or verifies exact canonical recovery bytes. */ +export const writeOrVerifyPrivateComposedJson = async ( + target: string, + value: unknown, +): Promise => { + const expected = canonicalComposedJson(value); + try { + const existing = await readFile(target, "utf8"); + if (existing !== expected) throw new TypeError("private composed artifact changed"); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + try { await writeFile(target, expected, { flag: "wx", mode: 0o600 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST" + || await readFile(target, "utf8") !== expected) throw error; + } +}; diff --git a/src/cli/composedRouting.test.ts b/src/cli/composedRouting.test.ts index c9bc7dd..7085588 100644 --- a/src/cli/composedRouting.test.ts +++ b/src/cli/composedRouting.test.ts @@ -53,5 +53,26 @@ describe("linked composed CLI dispatch", () => { await assert.rejects(stat(out)); } }); -}); + it("formats asynchronous composed-command failures through the CLI boundary", async () => { + const fixture = await project(); + const chunks: string[] = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + const code = await runCli(["run", fixture.simfile], { + runComposed: async () => { + await Promise.resolve(); + throw new Error("composed command failed asynchronously"); + }, + }); + assert.equal(code, 1); + assert.equal(chunks.join(""), "composed command failed asynchronously\n"); + } finally { + process.stderr.write = originalWrite; + } + }); +}); diff --git a/src/cli/composedRunBootstrap.test.ts b/src/cli/composedRunBootstrap.test.ts index ac29f68..c8774bb 100644 --- a/src/cli/composedRunBootstrap.test.ts +++ b/src/cli/composedRunBootstrap.test.ts @@ -1,18 +1,18 @@ import assert from "node:assert/strict"; -import { access, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import type { Simfile } from "../schema/index.js"; -import { composedOrganizationExportLifecycleInvocationId } from - "../compose/finalize-organization.js"; +import * as compose from "../compose/index.js"; +import { composedOrganizationExportLifecycleInvocationId } from "../compose/finalize-organization.js"; import { composedDeploymentName, composedHandoffRunEnvironment, - composedProviderLifecycleInvocations, composedOrganizationContainerName, composedOrganizationUnitId, + composedProviderLifecycleInvocations, prepareLinkedComposedRun, } from "./composedRunBootstrap.js"; import type { ParsedRunOptions } from "./runArguments.js"; @@ -20,59 +20,81 @@ import type { ParsedRunOptions } from "./runArguments.js"; const simfile = { clock: { seed: "neutral-seed" } } as Simfile; const options = (root: string, overrides: Partial = {}): ParsedRunOptions => ({ local: false, outDir: path.join(root, "run"), path: path.join(root, "Simfile"), - view: false, ...overrides, + targetContext: "local_test", view: false, ...overrides, }); +const executable = async (file: string): Promise => { + await writeFile(file, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await chmod(file, 0o700); +}; +const assertNoSupportState = async (root: string): Promise => { + await assert.rejects(access(path.join(root, ".simfile-composed"))); +}; +const bypassedGate = { assert_spawnfile_capabilities: async () => undefined }; -test("composed organization container names are deterministic DNS labels", () => { +test("composed organization names and handoff identity are deterministic", () => { const name = composedOrganizationContainerName("run-one"); assert.match(name, /^simfile-org-[a-f0-9]{16}$/u); - assert.equal(name, composedOrganizationContainerName("run-one")); -}); - -test("composed deployment names are deterministic Spawnfile identifiers", () => { - const name = composedDeploymentName("run-one"); - assert.match(name, /^simfile-[a-f0-9]{16}$/u); - assert.equal(name, composedDeploymentName("run-one")); - assert.doesNotMatch(name, /_/u); - assert.equal(composedOrganizationUnitId("run-one"), `${name}-container`); + assert.equal(composedDeploymentName("run-one"), composedDeploymentName("run-one")); + assert.equal(composedOrganizationUnitId("run-one"), `${composedDeploymentName("run-one")}-container`); + assert.deepEqual(composedHandoffRunEnvironment("run-one"), { NOOPOLIS_RUN_ID: "run-one" }); + const invocations = composedProviderLifecycleInvocations("run-one", `sha256:${"a".repeat(64)}`); + assert.equal(invocations.export, + composedOrganizationExportLifecycleInvocationId(`sha256:${"a".repeat(64)}`)); }); -test("composed handoff environment correlates the exact authorized run identity", () => { - assert.deepEqual(composedHandoffRunEnvironment("run-one"), { - NOOPOLIS_RUN_ID: "run-one", - }); - assert.throws(() => composedHandoffRunEnvironment("invalid run id")); +test("compose public barrel keeps project-preparation validation private", () => { + assert.equal("validateComposedProjectPreparation" in compose, false); }); -test("composed bootstrap persists the finalizer's exact export lifecycle identity", () => { - const requestDigest = `sha256:${"a".repeat(64)}`; - const invocations = composedProviderLifecycleInvocations("run-one", requestDigest); - assert.equal(invocations.export, - composedOrganizationExportLifecycleInvocationId(requestDigest)); - assert.notEqual(invocations.export, invocations.up); - assert.notEqual(invocations.export, invocations.down); +test("installed Spawnfile 0.1.14 fails the same read-only preflight as direct composed CLI", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-gate-")); + try { + const spawnfile = path.join(root, "spawnfile"); + await writeFile(spawnfile, [ + "#!/usr/bin/env node", + "const args = process.argv.slice(2).join(' ');", + "if (args === '--version') process.stdout.write('0.1.14\\n');", + "else if (args === '--help') process.stdout.write('compile target validate\\n');", + "else if (args === 'target --help') process.stdout.write('resolve_config\\n');", + "else if (args === 'target resolve_config --help') process.stdout.write('--evidence-destination --prepared-plan\\n');", + "else process.exitCode = 2;", + ].join("\n"), { mode: 0o700 }); + await chmod(spawnfile, 0o700); + await assert.rejects(prepareLinkedComposedRun({ + environment: { PATH: root, SPAWNFILE_BIN: spawnfile }, + linked_spawnfile_path: path.join(root, "Spawnfile"), options: options(root), simfile, + simfile_path: path.join(root, "Simfile"), source_text: "source", + }), /evidence_export_helper_capability_unverifiable/u); + await assertNoSupportState(root); + } finally { await rm(root, { force: true, recursive: true }); } }); -test("composed bootstrap rejects occupied output before creating support state", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-preflight-")); +test("composed bootstrap never falls back to PATH for Spawnfile", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-provider-")); try { - await mkdir(path.join(root, "run")); + await executable(path.join(root, "spawnfile")); await assert.rejects(prepareLinkedComposedRun({ - linked_spawnfile_path: path.join(root, "Spawnfile"), options: options(root), - simfile, simfile_path: path.join(root, "Simfile"), source_text: "source", - }), /output path already exists/u); - await assert.rejects(access(path.join(root, ".simfile-composed"))); + environment: { PATH: root }, linked_spawnfile_path: path.join(root, "Spawnfile"), + options: options(root), simfile, simfile_path: path.join(root, "Simfile"), source_text: "source", + }, bypassedGate), /SPAWNFILE_BIN must be an absolute installed executable path/u); + await assertNoSupportState(root); } finally { await rm(root, { force: true, recursive: true }); } }); -test("composed bootstrap validates run identity before lifecycle prerequisites", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-identity-")); +test("output and run identity are rejected before the provider seam", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-input-")); try { + await executable(path.join(root, "spawnfile")); + await mkdir(path.join(root, "run")); await assert.rejects(prepareLinkedComposedRun({ - linked_spawnfile_path: path.join(root, "Spawnfile"), - options: options(root, { runId: "invalid run id" }), simfile, - simfile_path: path.join(root, "Simfile"), source_text: "source", - })); - await assert.rejects(access(path.join(root, ".simfile-composed"))); + environment: { PATH: root }, linked_spawnfile_path: path.join(root, "Spawnfile"), + options: options(root), simfile, simfile_path: path.join(root, "Simfile"), source_text: "source", + }, bypassedGate), /output path already exists/u); + await assert.rejects(prepareLinkedComposedRun({ + environment: { PATH: root }, linked_spawnfile_path: path.join(root, "Spawnfile"), + options: options(root, { outDir: path.join(root, "different"), runId: "invalid run id" }), + simfile, simfile_path: path.join(root, "Simfile"), source_text: "source", + }, bypassedGate)); + await assertNoSupportState(root); } finally { await rm(root, { force: true, recursive: true }); } }); diff --git a/src/cli/composedRunBootstrap.ts b/src/cli/composedRunBootstrap.ts index 6ddfe4a..a1556ec 100644 --- a/src/cli/composedRunBootstrap.ts +++ b/src/cli/composedRunBootstrap.ts @@ -1,129 +1,50 @@ -import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import { z } from "zod"; -import { - composedOrganizationExportLifecycleInvocationId, - composedRunIdSchema, - createComposedRunRequestDigest, - parseComposedExecution, - parseComposedRunRequest, - type ComposedExecution, - type ComposedProjectBinding, - type ComposedProjectPreparation, - type ComposedRunRequest, -} from "../compose/index.js"; -import { canonicalComposedJson, digestComposedJson } from "../compose/json.js"; +import { composedRunIdSchema } from "../compose/index.js"; import type { Simfile } from "../schema/index.js"; +import { runSpawnfileRevokeCredentialSource } from "../spawnfile/bootstrapCli.js"; +import { finalizeComposedBootstrap } from "./composedBootstrapFinalize.js"; +import { prepareLocalComposedBootstrap } from "./composedBootstrapLocal.js"; import { - runSpawnfileCompile, - runSpawnfileDeriveBundlePolicy, - runSpawnfilePrepareContainerBundle, - runSpawnfileProvisionCredentials, - runSpawnfileRevokeCredentialSource, - runSpawnfileSelectTarget, - type SpawnfileCredentialProvisioningReceipt, - type SpawnfileSelectedTarget, -} from "../spawnfile/bootstrapCli.js"; -import { runSpawnfileConfigProducer } from "../spawnfile/process.js"; -import { RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS } from - "../world-artifact/index.js"; -import { - compileReportMemberEngines, - compileReportMoltnetReleaseExpectation, - deriveCompiledOrganizationArtifactDigest, - parseSpawnfileCompileReport, -} from "./compiledOrganizationIdentity.js"; -import { projectCredentialBindingNames } from "./credentialBindingProjection.js"; + assertComposedRunPathAvailable, + composedCommandMode, + createComposedBootstrapPaths, + resolveComposedRunIdentity, +} from "./composedBootstrapPaths.js"; +import { preserveComposedBootstrapFailure } from "./composedBootstrapRecovery.js"; +import type { LinkedComposedBootstrap } from "./composedBootstrapState.js"; +import { withComposedSupportRoot } from "./composedSupportRoot.js"; +import { admitComposedSpawnfile } from "./composedSpawnfileAdmission.js"; import type { ParsedRunOptions } from "./runArguments.js"; -const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); -const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); - -const sha = (bytes: Uint8Array | string): `sha256:${string}` => - `sha256:${createHash("sha256").update(bytes).digest("hex")}`; -const key = (domain: string, value: unknown): string => - digestComposedJson(domain, value).slice(7, 39); -export const composedOrganizationContainerName = (runId: string): string => - `simfile-org-${key("simfile.composed-container.v1", runId).slice(0, 16)}`; -export const composedDeploymentName = (runId: string): string => - `simfile-${key("simfile.composed-deployment.v1", runId).slice(0, 16)}`; -export const composedOrganizationUnitId = (runId: string): string => - `${composedDeploymentName(runId)}-container`; -export const composedHandoffRunEnvironment = (runId: string): Readonly> => - Object.freeze({ NOOPOLIS_RUN_ID: composedRunIdSchema.parse(runId) }); -export const composedProviderLifecycleInvocations = (runId: string, requestDigest: string) => - Object.freeze({ - down: `lci_${key("simfile.composed-lifecycle.down.v1", runId)}`, - export: composedOrganizationExportLifecycleInvocationId(requestDigest), - up: `lci_${key("simfile.composed-lifecycle.up.v1", runId)}`, - }); -const environmentValue = (name: string): string | undefined => { - const value = process.env[name]; - return value === undefined || value.length === 0 ? undefined : value; -}; -const executable = async (explicit?: string): Promise => { - const candidates = explicit === undefined - ? (process.env.PATH ?? "").split(path.delimiter).filter(Boolean) - .map((directory) => path.resolve(directory, "spawnfile")) - : [path.resolve(explicit)]; - for (const candidate of candidates) { - try { await access(candidate, constants.X_OK); return candidate; } catch { /* continue */ } - } - throw new TypeError("Spawnfile executable is unavailable"); -}; -const writePrivateJson = async (target: string, value: unknown): Promise => { - await writeFile(target, canonicalComposedJson(value), { flag: "wx", mode: 0o600 }); -}; -const targetConfig = async (input: Readonly<{ - command: string; - environment: NodeJS.ProcessEnv; - selector: string; - signal?: AbortSignal; - workdir: string; -}>): Promise => runSpawnfileConfigProducer({ - args: [input.selector], command: input.command, cwd: input.workdir, - env: input.environment, signal: input.signal, -}); -const loadBinding = async (simfilePath: string, simfile: Simfile): Promise => { - const reference = simfile.world_sidecar?.binding; - if (reference === undefined) throw new TypeError("linked composed project has no binding"); - const modulePath = path.resolve(path.dirname(simfilePath), reference); - const loaded = await import(pathToFileURL(modulePath).href) as Record; - const binding = loaded.composedProjectBinding as ComposedProjectBinding | undefined; - if (binding?.version !== "simfile.composed-project-binding.v1" - || typeof binding.prepareComposedProject !== "function") { - throw new TypeError("linked composed project binding is invalid"); - } - return binding; -}; -export interface LinkedComposedBootstrap { - readonly auth: SpawnfileCredentialProvisioningReceipt; - readonly compile_fingerprint: string; - readonly execution: ComposedExecution; - readonly journal_path: string; - readonly organization_evidence_directory: string; - readonly preparation: ComposedProjectPreparation; - readonly request: ComposedRunRequest; - readonly run_id: string; - readonly run_path: string; - readonly source_handles: readonly string[]; - readonly support_root: string; - readonly trusted_project_root: string; - readonly world_evidence_directory: string; -} +export { + composedDeploymentName, + composedHandoffRunEnvironment, + composedOrganizationContainerName, + composedOrganizationUnitId, + composedProviderLifecycleInvocations, +} from "./composedBootstrapContract.js"; +export type { LinkedComposedBootstrap } from "./composedBootstrapState.js"; const revokeCredentialSources = async ( - cli: Parameters[0], - sources: readonly string[], + bootstrap: LinkedComposedBootstrap, ): Promise => { const failures: unknown[] = []; - for (const source_handle of sources) { - try { await runSpawnfileRevokeCredentialSource(cli, { source_handle }); } + const provider = bootstrap.execution.provider; + const context = { + bootstrapLocalExecutableIdentity: { + path: provider.spawnfile_bin, + sha256: provider.spawnfile_executable_sha256 as `sha256:${string}`, + }, + cwd: provider.spawnfile_cwd, + env: provider.process_environment === undefined + ? process.env : { ...process.env, ...provider.process_environment }, + spawnfileBin: provider.spawnfile_bin, + }; + for (const source_handle of bootstrap.source_handles) { + try { await runSpawnfileRevokeCredentialSource(context, { source_handle }); } catch (error) { failures.push(error); } } if (failures.length > 0) throw new AggregateError( @@ -131,269 +52,62 @@ const revokeCredentialSources = async ( ); }; -export const revokeLinkedComposedSources = async ( - bootstrap: LinkedComposedBootstrap, -): Promise => revokeCredentialSources({ - cwd: bootstrap.execution.provider.spawnfile_cwd, - env: bootstrap.execution.provider.process_environment === undefined - ? process.env : { ...process.env, ...bootstrap.execution.provider.process_environment }, - spawnfileBin: bootstrap.execution.provider.spawnfile_bin, -}, bootstrap.source_handles); +export const revokeLinkedComposedSources = revokeCredentialSources; -/** Resolves operator inputs and prepares only durable prerequisites through public Spawnfile CLIs. */ +/** Creates durable bootstrap authority before any target/helper/auth mutation. */ export const prepareLinkedComposedRun = async (input: Readonly<{ + environment?: NodeJS.ProcessEnv; linked_spawnfile_path: string; options: ParsedRunOptions; signal?: AbortSignal; simfile: Simfile; simfile_path: string; source_text: string; -}>): Promise => { - const simfilePath = path.resolve(input.simfile_path); - const spawnfilePath = path.resolve(input.linked_spawnfile_path); +}>, _legacyDependencies?: unknown): Promise => { const seed = z.string().min(1).max(4_096).parse( input.options.seed ?? input.simfile.clock.seed, ); - const runId = composedRunIdSchema.parse(input.options.runId - ?? (seed.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "run")); - const runPath = path.resolve(input.options.outDir ?? `runs/${runId}`); - try { - await lstat(runPath); - throw new TypeError("composed output path already exists"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - const supportRoot = path.resolve(environmentValue("SIMFILE_COMPOSED_SUPPORT_ROOT") - ?? path.join(path.dirname(runPath), ".simfile-composed", runId)); - await mkdir(path.dirname(supportRoot), { recursive: true, mode: 0o700 }); - await mkdir(supportRoot, { mode: 0o700 }); - const directories = Object.freeze({ - auth: path.join(supportRoot, "auth"), compiled: path.join(supportRoot, "compiled"), - organizationEvidence: path.join(supportRoot, "evidence", "organization"), - worldEvidenceArchive: path.join(supportRoot, "evidence", "world.tar"), - worldEvidence: path.join(supportRoot, "evidence", "world"), - }); - await Promise.all([mkdir(directories.auth, { mode: 0o700 }), - mkdir(path.dirname(directories.organizationEvidence), { recursive: true, mode: 0o700 })]); - const architectureInput = environmentValue("SPAWNFILE_MOLTNET_TARGET_ARCH") ?? "amd64"; - if (architectureInput !== "amd64" && architectureInput !== "arm64") { - throw new TypeError("Spawnfile target architecture is invalid"); - } - const architecture: "amd64" | "arm64" = architectureInput; - const platform = { architecture, os: "linux" as const }; - const baseImageConfigDigest = digest.parse( - environmentValue("SIMFILE_WORLD_BASE_IMAGE_CONFIG_DIGEST"), - ); - const selector = identifier.parse(environmentValue("SPAWNFILE_TARGET_SELECTOR") ?? "gpu-4090"); - const authProfile = identifier.parse(environmentValue("SPAWNFILE_AUTH_PROFILE") ?? "simfile-live"); - const producer = environmentValue("SPAWNFILE_TARGET_CONFIG_PRODUCER") - ?? "target-config-producer"; - const spawnfileBin = await executable(environmentValue("SPAWNFILE_BIN")); - const projectRoot = path.dirname(simfilePath); - const targetPlanPath = path.join(supportRoot, "target-plan.json"); - const processEnvironment: Record = { - ...composedHandoffRunEnvironment(runId), - SIMFILE_COMPOSED_TARGET_PLAN: targetPlanPath, - SPAWNFILE_HOME: directories.auth, - SPAWNFILE_MOLTNET_TARGET_ARCH: architecture, - }; - const releaseDirectory = environmentValue("SPAWNFILE_MOLTNET_RELEASE_DIR"); - if (releaseDirectory !== undefined) { - processEnvironment.SPAWNFILE_MOLTNET_RELEASE_DIR = path.resolve(releaseDirectory); - } - const environment = { ...process.env, ...processEnvironment }; - const cli = { cwd: projectRoot, env: environment, spawnfileBin }; - const organizationContainer = composedOrganizationContainerName(runId); - const binding = await loadBinding(simfilePath, input.simfile); - const preparation = await binding.prepareComposedProject({ - base_image_config_digest: baseImageConfigDigest, - evidence_root: "/var/lib/simfile/evidence", internal_port: 4070, - organization_container_name: organizationContainer, platform, run_id: runId, - secret_root: "/run/spawnfile-secrets", seed, simfile_path: simfilePath, - spawnfile_path: spawnfilePath, - }); - const bundle = preparation.bundle; - const credentialProjection = projectCredentialBindingNames(preparation); - const claims = { - archiveDigest: bundle.archive_sha256, - artifactDigest: bundle.manifest.artifact.service_digest, - baseImageConfigDigest, bundleDigest: bundle.manifest.digest, - entrypoint: bundle.manifest.entrypoint, - launcherDigest: bundle.manifest.launcher.sha256, - networkAlias: bundle.manifest.network.dns_alias, platform, - }; - const policy = await runSpawnfileDeriveBundlePolicy(cli, claims, input.signal); - let config = await targetConfig({ command: producer, environment, selector, - signal: input.signal, workdir: projectRoot }); - let selected: SpawnfileSelectedTarget; - try { - selected = await runSpawnfileSelectTarget(cli, { request: { - idempotency_key: `idem_${key("simfile.composed-select-target.v1", { runId, selector })}`, - operation: "select_target", target_reference: selector, - version: "spawnfile.target-resource.request.v1", - }, signal: input.signal, target_config_stdin: config }); - } finally { config.fill(0); } - await writePrivateJson(targetPlanPath, { - evidence_destination: directories.worldEvidenceArchive, - prepared_artifact_mapping: { - archive_digest: bundle.archive_sha256, - artifact_manifest_digest: bundle.manifest.artifact.service_digest, - base_image_config_digest: baseImageConfigDigest, - build_policy_digest: policy.build_policy_digest, - bundle_digest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, - launcher_digest: bundle.manifest.launcher.sha256, - network_alias: bundle.manifest.network.dns_alias, platform, - platform_digest: policy.platform_digest, - }, - run_id: runId, target_selector: selector, - version: "simfile.composed-target-plan.v1", - }); - const bundleRequest = { - archive_base64: Buffer.from(bundle.archive_bytes).toString("base64"), - archive_digest: bundle.archive_sha256, - archive_entries: RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS, - artifact_digest: bundle.manifest.artifact.service_digest, - build_policy_digest: policy.build_policy_digest, - bundle_digest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, - idempotency_key: `idem_${key("simfile.composed-prepare-bundle.v1", { - archive: bundle.archive_sha256, selected, - })}`, - launcher_digest: bundle.manifest.launcher.sha256, - network_alias: bundle.manifest.network.dns_alias, platform, - platform_digest: policy.platform_digest, - selected_target: { fingerprint: selected.fingerprint, handle: selected.handle }, - version: "spawnfile.target-local-container-bundle.prepare-request.v1", - } as const; - config = await targetConfig({ command: producer, environment, selector, - signal: input.signal, workdir: projectRoot }); - try { - await runSpawnfilePrepareContainerBundle(cli, { request: bundleRequest, - signal: input.signal, target_config_stdin: config }); - } finally { config.fill(0); } - const report = parseSpawnfileCompileReport(await runSpawnfileCompile(cli, { - compiled_output_directory: directories.compiled, - organization_path: spawnfilePath, signal: input.signal, - })); - if (report.container.moltnet.release.architecture !== architecture) { - throw new TypeError("Spawnfile Moltnet release architecture drift"); + const normalized = seed.replace(/[^A-Za-z0-9_.-]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + const runId = composedRunIdSchema.parse(input.options.runId ?? (normalized || "run")); + const identity = resolveComposedRunIdentity({ out_dir: input.options.outDir, run_id: runId }); + await assertComposedRunPathAvailable(identity.run_path); + const environment = input.environment ?? process.env; + const paths = createComposedBootstrapPaths({ environment, + run_id: runId, run_path: identity.run_path }); + const simfilePath = path.resolve(input.simfile_path); + const spawnfilePath = path.resolve(input.linked_spawnfile_path); + if (input.options.targetContext === undefined) { + throw new TypeError("linked composed run requires --context"); } - const selectedReceipt = { ...selected, - version: "spawnfile.target-resource.selected-target.v1" as const }; - const selectedFile = path.join(supportRoot, "selected-target.json"); - await writePrivateJson(selectedFile, selectedReceipt); - const selectedDigest = sha(canonicalComposedJson(selectedReceipt)); - const organizationArtifactDigest = deriveCompiledOrganizationArtifactDigest( - report.compile_fingerprint, - ); - const simfileDigest = sha(input.source_text); - const spawnfileDigest = sha(await readFile(spawnfilePath)); - const descriptorDigest = digestComposedJson("simfile.composed-project-descriptor.v1", { - organization: report.compile_fingerprint, simfile: simfileDigest, - spawnfile: spawnfileDigest, world: bundle.manifest.digest, - }); - const envFile = path.join(supportRoot, "organization.env"); - const bindingsFile = path.join(supportRoot, "world-bindings.json"); - const grantsFile = path.join(supportRoot, "resolved-world-grants.json"); - await writePrivateJson(grantsFile, { - grants: preparation.world_members.map((member) => ({ - capability_manifest: member.capability_manifest, - member_id: member.id, principal_id: member.principal_id, - })), - run_id: runId, version: "spawnfile.auth.resolved-world-grants.v1", - world_instance_id: preparation.readiness_expectation.world_instance_id, - }); - const auth = await runSpawnfileProvisionCredentials(cli, { - env_file: envFile, - request: { - credentials: credentialProjection.credentials, descriptor_digest: descriptorDigest, - model_engine_auth: { kind: "codex", profile: authProfile }, - run_id: runId, scope: "world", selected_target: selectedReceipt, - version: "spawnfile.auth.credential-provisioning.request.v1", - world_bindings: { - json_url: `http://${bundle.manifest.network.dns_alias}:${bundle.manifest.network.internal_port}/v1/world`, - mcp_url: `http://${bundle.manifest.network.dns_alias}:${bundle.manifest.network.internal_port}/mcp`, - members: credentialProjection.world_members.map(({ id, principal_id, token_credential_name }) => - ({ id, principal_id, token_credential_name })), - world_instance_id: preparation.readiness_expectation.world_instance_id, - }, - }, - resolved_grants_file: grantsFile, signal: input.signal, - world_bindings_file: bindingsFile, - }); - const sourceHandles = Object.freeze(auth.credentials.map(({ source_handle }) => source_handle)); + const targetContext = input.options.targetContext; + const admitted = await admitComposedSpawnfile({ environment, + project_root: path.dirname(simfilePath), run_id: runId, + signal: input.signal, spawnfile_home: paths.auth }); + const state = await withComposedSupportRoot(paths.support_root, async () => + prepareLocalComposedBootstrap({ + admitted, + command_mode: composedCommandMode(input.options.composedMode), + environment, + paths, + run_id: runId, + seed, + signal: input.signal, + simfile: input.simfile, + simfile_path: simfilePath, + source_text: input.source_text, + spawnfile_path: spawnfilePath, + target_context: targetContext, + })); try { - const worldBindingsDigest = digest.parse(auth.world_bindings_digest); - const sourceByName = new Map(auth.credentials.map(({ name, source_handle }) => - [name, source_handle])); - const request = parseComposedRunRequest({ - descriptor_digest: descriptorDigest, mode: "live", - organization: { artifact_digest: organizationArtifactDigest, - source_digest: spawnfileDigest, world_bindings_digest: worldBindingsDigest }, - required_world_capabilities: ["simfile.world-decision-claim.v1"], - run_id: runId, source_digest: simfileDigest, - target: { auth_profile: authProfile, selector }, - version: "simfile.composed-run-request.v1", - world: { artifact_manifest_digest: bundle.manifest.artifact.service_digest, - bundle_digest: bundle.manifest.digest, - runtime_abi: "simfile.world-sidecar-runtime.v1" }, - }); - const requestDigest = createComposedRunRequestDigest(request); - const execution = parseComposedExecution({ - configuration: { - organization_expectation: { - deployment_name: composedDeploymentName(runId), - member_engines: compileReportMemberEngines(report), - moltnet_release: compileReportMoltnetReleaseExpectation(report), - selected_target_receipt_digest: selectedDigest, - unit_id: composedOrganizationUnitId(runId), - world_binding_digest: worldBindingsDigest, - }, - readiness_expectation: preparation.readiness_expectation, - terminal_tick: preparation.terminal_tick, - topology_expectation: { selected_target: { - fingerprint: selected.fingerprint, handle: selected.handle, - } }, - }, - provider: { - compiled_output_directory: directories.compiled, - evidence_destination_directory: directories.organizationEvidence, - evidence_mount_path: "/var/lib/simfile/evidence", - lifecycle_invocations: composedProviderLifecycleInvocations(runId, requestDigest), - organization_handoff: { env_file: envFile, - selected_target_receipt_file: selectedFile, world_bindings_file: bindingsFile }, - organization_container_name: organizationContainer, - organization_image_tag: `simfile-org-${key("simfile.composed-image.v1", runId).slice(0, 16)}:run`, - organization_path: spawnfilePath, process_environment: processEnvironment, - spawnfile_bin: spawnfileBin, spawnfile_cwd: projectRoot, - target_config_producer: { args: [selector], command: producer, - transport: "stdout_to_spawnfile_stdin" }, - terminal_artifact: { id: "composed_terminal", max_bytes: 131_072, - path: "/tmp/spawnfile-public/composed-terminal.json" }, - world_evidence_export: { - archive_path: directories.worldEvidenceArchive, - destination_directory: directories.worldEvidence, - }, - world_readiness_port: bundle.manifest.network.internal_port, - }, - secret_bindings: credentialProjection.secret_bindings.map((binding) => ({ - name: binding.name, scope: binding.scope, - source_handle: z.string().parse(sourceByName.get(binding.credential_name)), - })), - version: "simfile.composed-execution.v1", - }); - return Object.freeze({ auth, compile_fingerprint: report.compile_fingerprint, execution, - journal_path: path.join(supportRoot, "journal", "phase-journal.json"), - organization_evidence_directory: directories.organizationEvidence, - preparation, request, run_id: runId, run_path: runPath, - source_handles: sourceHandles, support_root: supportRoot, - trusted_project_root: projectRoot, - world_evidence_directory: directories.worldEvidence }); + return await finalizeComposedBootstrap(state, input.signal); } catch (error) { - try { await revokeCredentialSources(cli, sourceHandles); } - catch (revocationError) { - throw new AggregateError([error, revocationError], - "composed bootstrap failed and credential revocation is incomplete"); + let recovery: Error; + try { recovery = await preserveComposedBootstrapFailure(state.journal_session, error); } + catch (preservationError) { + throw new AggregateError([error, preservationError], + "composed bootstrap failed and recovery state could not be preserved"); } - throw error; + throw recovery; } }; diff --git a/src/cli/composedRunCommand.ts b/src/cli/composedRunCommand.ts index 7fa72a0..1518923 100644 --- a/src/cli/composedRunCommand.ts +++ b/src/cli/composedRunCommand.ts @@ -1,17 +1,10 @@ import { assertComposedDecisionInputs, attachComposedViewer, - composedCommandExitCode, composedRunConfiguration, - createComposedCommandReceipt, - createComposedJournalSession, createComposedLiveViewerProjection, - createComposedPhaseJournal, - deriveComposedLiveEvidence, - replayComposedRunRecord, runPreflightedComposedRun, serializeComposedReceipt, - writeComposedFinalReceipt, writeComposedProgress, type CompletedComposedRun, type ComposedViewerAttachment, @@ -26,8 +19,15 @@ import { } from "../spawnfile/productionViewerProjection.js"; import { prepareLinkedComposedRun, revokeLinkedComposedSources } from "./composedRunBootstrap.js"; -import { createLinkedComposedRecord, sealLinkedComposedRecord } from +import { + runComposedFailureCleanup, + throwAfterComposedFailureCleanup, +} from "./composedFailureCleanup.js"; +import { createLinkedComposedRecord } from "./composedRunArtifacts.js"; +import { finalizeLinkedComposedRun } from "./composedRunCompletion.js"; +import { removeComposedSupportRoot } from "./composedSupportRoot.js"; +import { ComposedBootstrapRecoveryError } from "./composedBootstrapRecovery.js"; import type { ParsedRunOptions } from "./runArguments.js"; export interface LinkedComposedRunInput { @@ -44,36 +44,31 @@ export type LinkedComposedRunCommand = ( const recoveryExitCode = (signal: "SIGINT" | "SIGTERM" | "failure" | "restart"): number => signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1; -const receiptViewer = ( - attachment: ComposedViewerAttachment | undefined, - projectionError?: string, -) => { - if (attachment === undefined) return { state: "disabled" as const }; - if (projectionError !== undefined) { - return { error: projectionError, state: "unavailable" as const }; - } - if (attachment.state === "attached") { - return { state: "attached" as const, url: attachment.url }; - } - return { error: attachment.error, state: "unavailable" as const }; -}; - /** Owns the one production route into the generic composed lifecycle. */ export const runLinkedComposedCommand: LinkedComposedRunCommand = async (input) => { assertComposedDecisionInputs({ simfile: input.simfile }); writeComposedProgress("Preparing linked composed run"); - const bootstrap = await prepareLinkedComposedRun(input); + let bootstrap; + try { bootstrap = await prepareLinkedComposedRun(input); } + catch (error) { + if (!(error instanceof ComposedBootstrapRecoveryError)) throw error; + process.stdout.write(serializeComposedReceipt(error.receipt)); + return recoveryExitCode(error.receipt.signal); + } + try { let record; try { record = await createLinkedComposedRecord(bootstrap); } catch (error) { - await revokeLinkedComposedSources(bootstrap); - throw error; + return throwAfterComposedFailureCleanup(error, [{ + label: "credential source revocation", + run: () => revokeLinkedComposedSources(bootstrap), + }, { + label: "private support-root removal", + run: () => removeComposedSupportRoot(bootstrap.support_root), + }]); } - const initial = createComposedPhaseJournal( - bootstrap.request, new Date().toISOString(), bootstrap.execution, - ); - const session = await createComposedJournalSession(bootstrap.journal_path, initial); + const session = bootstrap.journal_session; let viewer: ComposedViewerAttachment | undefined; let projection: ComposedLiveViewerProjection | undefined; let projectionObserver: ProductionViewerProjectionObserver | undefined; @@ -109,6 +104,7 @@ export const runLinkedComposedCommand: LinkedComposedRunCommand = async (input) try { const ports = createProductionComposedRunPorts({ execution: bootstrap.execution, journal_session: session, + target_provider: bootstrap.target_provider, }); outcome = await runPreflightedComposedRun({ configuration: composedRunConfiguration(bootstrap.execution), @@ -124,11 +120,16 @@ export const runLinkedComposedCommand: LinkedComposedRunCommand = async (input) request: bootstrap.request, }); } catch (error) { - await projectionObserver?.close(); - await record.abort(); - if (viewer?.state === "attached") await viewer.close(); - await revokeLinkedComposedSources(bootstrap); - throw error; + return throwAfterComposedFailureCleanup(error, [ + ...(projectionObserver === undefined ? [] : [{ + label: "viewer projection close", run: () => projectionObserver.close(), + }]), + { label: "staging record abort", run: () => record.abort() }, + ...(viewer?.state !== "attached" ? [] : [{ + label: "viewer close", run: () => viewer.close(), + }]), + { label: "credential source revocation", run: () => revokeLinkedComposedSources(bootstrap) }, + ]); } const projectionObservation = await projectionObserver?.close(); if (projectionObservation !== undefined) { @@ -140,63 +141,23 @@ export const runLinkedComposedCommand: LinkedComposedRunCommand = async (input) } } if (outcome.receipt.status === "recovery_required") { - await record.abort(); - if (viewer?.state === "attached") await viewer.close(); + const cleanupFailures = await runComposedFailureCleanup([ + { label: "staging record abort", run: () => record.abort() }, + ...(viewer?.state !== "attached" ? [] : [{ + label: "viewer close", run: () => viewer.close(), + }]), + ]); + if (cleanupFailures.length > 0) { + throw new AggregateError(cleanupFailures, + "composed recovery was preserved but local cleanup is incomplete"); + } process.stdout.write(serializeComposedReceipt(outcome.receipt)); return recoveryExitCode(outcome.receipt.signal); } - const completed = outcome as CompletedComposedRun; - let revocationAttempted = false; - try { - writeComposedProgress("Reconciling and sealing exported evidence"); - if (projection !== undefined) { - try { - const captured = await projection.finalize(record); - if (captured.publications === 0) { - projectionError ??= "no authenticated viewer projection was captured"; - } - } catch (error) { - projectionError = error instanceof Error ? error.message : String(error); - writeComposedProgress(`Viewer projection evidence unavailable: ${projectionError}`); - } - } - const sealed = await sealLinkedComposedRecord({ bootstrap, - lifecycle: completed, record }); - if (viewer?.state === "attached") { - try { - const seal = await viewer.awaitSeal(); - if (seal.status === "failed") { - projectionError ??= seal.error ?? "viewer seal reconciliation failed"; - } - } catch (error) { - projectionError ??= error instanceof Error ? error.message : String(error); - } - } - const replay = await replayComposedRunRecord({ - adapter: bootstrap.preparation.replay_adapter, - run_dir: sealed.out_dir, - }); - writeComposedProgress(`Exact replay verified at tick ${replay.terminal_tick}`); - const liveEvidence = await deriveComposedLiveEvidence({ - accepted_actions_path: "actions/accepted.json", - principals_path: "identity/principals.json", - run_dir: sealed.out_dir, - }); - revocationAttempted = true; - await revokeLinkedComposedSources(bootstrap); - const receipt = createComposedCommandReceipt({ - journal: completed.journal, lifecycle_receipt: completed.receipt, - live_evidence: liveEvidence, - manifest_digest: `sha256:${sealed.manifest_sha256}`, - run_path: sealed.out_dir, viewer: receiptViewer(viewer, projectionError), - }); - writeComposedFinalReceipt(receipt); - return composedCommandExitCode(receipt); + return await finalizeLinkedComposedRun({ bootstrap, + completed: outcome as CompletedComposedRun, projection, projection_error: projectionError, + record, viewer }); } finally { - try { - if (!revocationAttempted) await revokeLinkedComposedSources(bootstrap); - } finally { - if (viewer?.state === "attached") await viewer.close(); - } + bootstrap.target_provider.close(); } }; diff --git a/src/cli/composedRunCompletion.ts b/src/cli/composedRunCompletion.ts new file mode 100644 index 0000000..f2a200b --- /dev/null +++ b/src/cli/composedRunCompletion.ts @@ -0,0 +1,108 @@ +import { + composedCommandExitCode, + createComposedCommandReceipt, + createComposedLifecycleReplaySmokeReceipt, + deriveComposedLiveEvidence, + replayComposedRunRecord, + verifyComposedTerminalOutcome, + writeComposedFinalReceipt, + writeComposedLifecycleReplaySmokeReceipt, + writeComposedProgress, + type CompletedComposedRun, + type ComposedLiveViewerProjection, + type ComposedRunRecord, + type ComposedViewerAttachment, +} from "../compose/index.js"; +import type { LinkedComposedBootstrap } from "./composedRunBootstrap.js"; +import { revokeLinkedComposedSources } from "./composedRunBootstrap.js"; +import { sealLinkedComposedRecord } from "./composedRunArtifacts.js"; + +const receiptViewer = ( + attachment: ComposedViewerAttachment | undefined, + projectionError?: string, +) => { + if (attachment === undefined) return { state: "disabled" as const }; + if (projectionError !== undefined) { + return { error: projectionError, state: "unavailable" as const }; + } + if (attachment.state === "attached") { + return { state: "attached" as const, url: attachment.url }; + } + return { error: attachment.error, state: "unavailable" as const }; +}; + +export const finalizeLinkedComposedRun = async (input: Readonly<{ + bootstrap: LinkedComposedBootstrap; + completed: CompletedComposedRun; + projection?: ComposedLiveViewerProjection; + projection_error?: string; + record: ComposedRunRecord; + viewer?: ComposedViewerAttachment; +}>): Promise => { + let projectionError = input.projection_error; + let revocationAttempted = false; + try { + writeComposedProgress("Reconciling and sealing exported evidence"); + if (input.projection !== undefined) { + try { + const captured = await input.projection.finalize(input.record); + if (captured.publications === 0) { + projectionError ??= "no authenticated viewer projection was captured"; + } + } catch (error) { + projectionError = error instanceof Error ? error.message : String(error); + writeComposedProgress(`Viewer projection evidence unavailable: ${projectionError}`); + } + } + const sealed = await sealLinkedComposedRecord({ bootstrap: input.bootstrap, + lifecycle: input.completed, record: input.record }); + if (input.viewer?.state === "attached") { + try { + const seal = await input.viewer.awaitSeal(); + if (seal.status === "failed") { + projectionError ??= seal.error ?? "viewer seal reconciliation failed"; + } + } catch (error) { + projectionError ??= error instanceof Error ? error.message : String(error); + } + } + const replay = await replayComposedRunRecord({ + adapter: input.bootstrap.preparation.replay_adapter, + run_dir: sealed.out_dir, + }); + verifyComposedTerminalOutcome(input.completed.journal, replay); + writeComposedProgress(`Exact replay verified at tick ${replay.terminal_tick}`); + if (input.bootstrap.command_mode === "lifecycle-replay-smoke") { + revocationAttempted = true; + await revokeLinkedComposedSources(input.bootstrap); + writeComposedLifecycleReplaySmokeReceipt(createComposedLifecycleReplaySmokeReceipt({ + journal: input.completed.journal, + lifecycle_receipt: input.completed.receipt, + manifest_digest: `sha256:${sealed.manifest_sha256}`, + replay, run_path: sealed.out_dir, + viewer: receiptViewer(input.viewer, projectionError), + })); + return 0; + } + const liveEvidence = await deriveComposedLiveEvidence({ + accepted_actions_path: "actions/accepted.json", + principals_path: "identity/principals.json", + run_dir: sealed.out_dir, + }); + revocationAttempted = true; + await revokeLinkedComposedSources(input.bootstrap); + const receipt = createComposedCommandReceipt({ + journal: input.completed.journal, lifecycle_receipt: input.completed.receipt, + live_evidence: liveEvidence, manifest_digest: `sha256:${sealed.manifest_sha256}`, + run_path: sealed.out_dir, viewer: receiptViewer(input.viewer, projectionError), + }); + writeComposedFinalReceipt(receipt); + return composedCommandExitCode(receipt); + } finally { + try { + if (!revocationAttempted) await revokeLinkedComposedSources(input.bootstrap); + } finally { + if (input.viewer?.state === "attached") await input.viewer.close(); + } + } +}; diff --git a/src/cli/composedSpawnfileAdmission.ts b/src/cli/composedSpawnfileAdmission.ts new file mode 100644 index 0000000..7898f5a --- /dev/null +++ b/src/cli/composedSpawnfileAdmission.ts @@ -0,0 +1,105 @@ +import path from "node:path"; + +import { + COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS, + captureBootstrapLocalExecutableIdentity, + type BootstrapLocalExecutableIdentity, + type BootstrapSpawnfileCliContext, +} from "../spawnfile/process.js"; +import { + assertSpawnfileCompositionCapabilities, + probeSpawnfilePublicCapabilities, +} from "../spawnfile/publicCapabilityProbe.js"; +import { bootstrapOption } from "./composedBootstrapPaths.js"; + +export interface AdmittedComposedSpawnfile { + readonly capability_contract_digest: `sha256:${string}`; + readonly context: BootstrapSpawnfileCliContext; + readonly identity: BootstrapLocalExecutableIdentity; + readonly package_version: "0.1.17"; + readonly process_environment: Readonly>; +} + +export const admitComposedSpawnfile = async (input: Readonly<{ + environment: NodeJS.ProcessEnv; + project_root: string; + run_id: string; + spawnfile_home: string; + signal?: AbortSignal; +}>): Promise => { + const configured = bootstrapOption(input.environment, "SPAWNFILE_BIN"); + if (configured === undefined || !path.isAbsolute(configured) + || path.normalize(configured) !== configured) { + throw new TypeError("SPAWNFILE_BIN must be an absolute installed executable path"); + } + const identity = await captureBootstrapLocalExecutableIdentity(configured); + const processEnvironment: Record = { + NOOPOLIS_RUN_ID: input.run_id, + SPAWNFILE_HOME: input.spawnfile_home, + }; + for (const name of ["SPAWNFILE_MOLTNET_RELEASE_DIR", + "SPAWNFILE_MOLTNET_TARGET_ARCH"] as const) { + const value = bootstrapOption(input.environment, name); + if (value !== undefined) processEnvironment[name] = name.endsWith("_DIR") + ? path.resolve(value) : value; + } + const environment = { ...input.environment, ...processEnvironment }; + const capabilities = await probeSpawnfilePublicCapabilities({ + cwd: input.project_root, environment, identity, signal: input.signal, + }); + assertSpawnfileCompositionCapabilities(capabilities); + if (capabilities.capabilities?.implementation.version !== "0.1.17") { + throw new TypeError("Simfile requires the exact Spawnfile 0.1.17 package contract"); + } + return Object.freeze({ + capability_contract_digest: capabilities.capabilities.command_rows_digest, + context: Object.freeze({ + bootstrapLocalExecutableIdentity: identity, + cwd: input.project_root, + env: environment, + spawnfileBin: identity.path, + timeoutMs: COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS, + }), + identity, + package_version: "0.1.17" as const, + process_environment: Object.freeze(processEnvironment), + }); +}; + +export const revalidateComposedSpawnfile = async (input: Readonly<{ + capability_contract_digest: string; + context: BootstrapSpawnfileCliContext; + signal?: AbortSignal; +}>): Promise => { + if (input.context.cwd === undefined || input.context.env === undefined) { + throw new TypeError("Spawnfile recovery context is incomplete"); + } + const identity = await captureBootstrapLocalExecutableIdentity(input.context.spawnfileBin); + if (identity.path !== input.context.bootstrapLocalExecutableIdentity.path + || identity.sha256 !== input.context.bootstrapLocalExecutableIdentity.sha256) { + throw new TypeError("Spawnfile executable identity changed"); + } + const probe = await probeSpawnfilePublicCapabilities({ cwd: input.context.cwd, + environment: input.context.env, identity, signal: input.signal }); + assertSpawnfileCompositionCapabilities(probe); + if (probe.capabilities?.command_rows_digest !== input.capability_contract_digest + || probe.capabilities.implementation.version !== "0.1.17") { + throw new TypeError("Spawnfile package contract changed"); + } +}; + +export const bindComposedTargetArchitecture = ( + admitted: AdmittedComposedSpawnfile, + architecture: "amd64" | "arm64", +): AdmittedComposedSpawnfile => { + const existing = admitted.process_environment.SPAWNFILE_MOLTNET_TARGET_ARCH; + if (existing !== undefined && existing !== architecture) { + throw new TypeError("Spawnfile target architecture contradicts the selected context"); + } + const processEnvironment = Object.freeze({ ...admitted.process_environment, + SPAWNFILE_MOLTNET_TARGET_ARCH: architecture }); + return Object.freeze({ ...admitted, + context: Object.freeze({ ...admitted.context, + env: { ...admitted.context.env, ...processEnvironment } }), + process_environment: processEnvironment }); +}; diff --git a/src/cli/composedSupportRoot.test.ts b/src/cli/composedSupportRoot.test.ts new file mode 100644 index 0000000..6570d82 --- /dev/null +++ b/src/cli/composedSupportRoot.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { removeComposedSupportRoot, withComposedSupportRoot } from "./composedSupportRoot.js"; + +test("failed composed preparation removes only its newly-created support root", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-support-root-")); + const sibling = path.join(root, "keep.txt"); + const support = path.join(root, ".simfile-composed", "failed-run"); + try { + await writeFile(sibling, "keep\n"); + await assert.rejects(withComposedSupportRoot(support, async (created) => { + await writeFile(path.join(created, "partial.json"), "{}\n"); + throw new Error("injected bootstrap failure"); + }), /injected bootstrap failure/u); + await assert.rejects(access(support)); + assert.equal(await readFile(sibling, "utf8"), "keep\n"); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("successful composed preparation retains its durable support root", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-support-root-")); + const support = path.join(root, ".simfile-composed", "successful-run"); + try { + const result = await withComposedSupportRoot(support, async (created) => { + await writeFile(path.join(created, "prepared.json"), "{}\n"); + return "prepared"; + }); + assert.equal(result, "prepared"); + assert.equal(await readFile(path.join(support, "prepared.json"), "utf8"), "{}\n"); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("transferred support-root ownership can be released without touching its sibling", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-support-root-release-")); + const support = path.join(root, ".simfile-composed", "released-run"); + const sibling = path.join(root, "keep.txt"); + try { + await writeFile(sibling, "keep\n"); + await withComposedSupportRoot(support, async (created) => { + await writeFile(path.join(created, "owned.json"), "{}\n"); + }); + await removeComposedSupportRoot(support); + await assert.rejects(access(support)); + assert.equal(await readFile(sibling, "utf8"), "keep\n"); + await assert.rejects(removeComposedSupportRoot(path.parse(support).root), /root is invalid/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/cli/composedSupportRoot.ts b/src/cli/composedSupportRoot.ts new file mode 100644 index 0000000..6233696 --- /dev/null +++ b/src/cli/composedSupportRoot.ts @@ -0,0 +1,38 @@ +import { mkdir, rm } from "node:fs/promises"; +import path from "node:path"; + +const assertSupportRoot = (supportRoot: string): void => { + if (!path.isAbsolute(supportRoot) || path.normalize(supportRoot) !== supportRoot + || supportRoot === path.parse(supportRoot).root) { + throw new TypeError("composed support root is invalid"); + } +}; + +/** Removes only the exact private root whose ownership a bootstrap returned. */ +export const removeComposedSupportRoot = async (supportRoot: string): Promise => { + assertSupportRoot(supportRoot); + await rm(supportRoot, { force: true, recursive: true }); +}; + +/** Owns a newly-created bootstrap root until its preparation callback succeeds. */ +export const withComposedSupportRoot = async ( + supportRoot: string, + prepare: (supportRoot: string) => Promise, +): Promise => { + assertSupportRoot(supportRoot); + await mkdir(path.dirname(supportRoot), { recursive: true, mode: 0o700 }); + await mkdir(supportRoot, { mode: 0o700 }); + try { + return await prepare(supportRoot); + } catch (error) { + try { + await removeComposedSupportRoot(supportRoot); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "composed bootstrap failed and its private support root could not be removed", + ); + } + throw error; + } +}; diff --git a/src/cli/composedWorldBindings.test.ts b/src/cli/composedWorldBindings.test.ts new file mode 100644 index 0000000..f3db6ae --- /dev/null +++ b/src/cli/composedWorldBindings.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; + +import { canonicalComposedJson } from "../compose/json.js"; +import { createComposedWorldBindings } from "./composedWorldBindings.js"; + +const sha = (value: string): string => + `sha256:${createHash("sha256").update(value).digest("hex")}`; + +test("world-binding prediction matches Spawnfile's canonical public artifact", () => { + const manifest = { effects: ["observe"], entity: "counter" }; + const result = createComposedWorldBindings({ + json_url: "http://world:4070/v1/world", + mcp_url: "http://world:4070/mcp", + members: [{ capability_manifest: manifest, id: "smoke", + principal_id: "agent:smoke", token_env: "SIMFILE_WORLD_TOKEN" }], + run_id: "run-one", + world_instance_id: "world-one", + }); + assert.equal(result.artifact.schema, "simfile.world-bindings.v1"); + const binding = (result.artifact.bindings as Array>)[0]!; + assert.equal(binding.capability_manifest_digest, sha(canonicalComposedJson(manifest))); + assert.equal(result.digest, sha(result.bytes)); + assert.equal(result.bytes, `${JSON.stringify(result.artifact, null, 2)}\n`); +}); + +test("world-binding prediction requires canonical Spawnfile agent principals", () => { + assert.throws(() => createComposedWorldBindings({ + json_url: "http://world:4070/v1/world", + mcp_url: "http://world:4070/mcp", + members: [{ capability_manifest: {}, id: "smoke", + principal_id: "principal:smoke", token_env: "SIMFILE_WORLD_TOKEN" }], + run_id: "run-one", + world_instance_id: "world-one", + }), /canonical agent identity/u); +}); diff --git a/src/cli/composedWorldBindings.ts b/src/cli/composedWorldBindings.ts new file mode 100644 index 0000000..1870c02 --- /dev/null +++ b/src/cli/composedWorldBindings.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { canonicalComposedJson } from "../compose/json.js"; + +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const environment = z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/u); + +export interface ComposedWorldBindingInput { + readonly capability_manifest: unknown; + readonly id: string; + readonly principal_id: string; + readonly token_env: string; +} + +const sha256 = (value: Uint8Array | string): `sha256:${string}` => + `sha256:${createHash("sha256").update(value).digest("hex")}`; + +/** Predicts Spawnfile's public, secret-free world-binding artifact before auth mutation. */ +export const createComposedWorldBindings = (input: Readonly<{ + json_url: string; + mcp_url: string; + members: readonly ComposedWorldBindingInput[]; + run_id: string; + world_instance_id: string; +}>): Readonly<{ artifact: Readonly>; bytes: string; + digest: `sha256:${string}` }> => { + const bindings = input.members.map((member) => { + const id = identifier.parse(member.id); + if (member.principal_id !== `agent:${id}`) { + throw new TypeError("composed world member principal must be its canonical agent identity"); + } + return { + member: { id, principal_id: member.principal_id }, + run_id: input.run_id, + world_instance_id: input.world_instance_id, + capability_manifest_digest: sha256(canonicalComposedJson(member.capability_manifest)), + token_env: environment.parse(member.token_env), + json: { auth: "bearer" as const, url: input.json_url }, + mcp: { auth: "bearer" as const, transport: "streamable_http" as const, + url: input.mcp_url }, + }; + }).sort((left, right) => left.member.principal_id < right.member.principal_id ? -1 + : left.member.principal_id > right.member.principal_id ? 1 + : left.member.id < right.member.id ? -1 : left.member.id > right.member.id ? 1 : 0); + if (bindings.length < 1 + || new Set(bindings.map(({ member }) => member.id)).size !== bindings.length + || new Set(bindings.map(({ token_env }) => token_env)).size !== bindings.length + || new Set(bindings.map(({ capability_manifest_digest }) => + capability_manifest_digest)).size !== bindings.length) { + throw new TypeError("composed world bindings are not uniquely correlated"); + } + const artifact = Object.freeze({ schema: "simfile.world-bindings.v1" as const, + bindings: Object.freeze(bindings) }); + const bytes = `${JSON.stringify(artifact, null, 2)}\n`; + return Object.freeze({ artifact, bytes, digest: sha256(bytes) }); +}; diff --git a/src/cli/index.ts b/src/cli/index.ts index 6ec8e6d..d620363 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,275 +1,45 @@ #!/usr/bin/env node import { realpathSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { ZodError } from "zod"; - -import { executeSimfileRun } from "../run/run-driver.js"; -import { type BindingDiagnostic, type Simfile, createBindingDiagnostics, loadSpawnfileReport, parseSimfileSource } from "../schema/index.js"; import { runObserveCommand } from "../observe/observeCommand.js"; import { runViewCommand } from "../view/index.js"; -import { runLinkedComposedCommand, type LinkedComposedRunCommand } from "./composedRunCommand.js"; +import type { LinkedComposedRunCommand } from "./composedRunCommand.js"; +import { formatCliError, simfileCliUsage } from "./cliShared.js"; import { runRecoverCli } from "./recover.js"; -import { parseRunArguments } from "./runArguments.js"; -import { resolveSimfileRunRoute } from "./runRoute.js"; - -const usage = (): string => [ - "Usage:", - " simfile validate [--json] [--spawnfile-report |]", - " simfile run [--view] [--out ] [--seed ] [--run-id ]", - " simfile run --local --ticks [--out ] [--seed ] [--run-id ] [--acts ] [--clock ] [--moltnet-artifact transcript|delivery] [--spawnfile-report |]", - " simfile observe [--json]", - " simfile view --state ", - " simfile view ", - " simfile recover --journal --run-id --authority-digest ", - " simfile view --help", - " simfile --help", - "" -].join("\n"); - -const formatError = (error: unknown): string => { - if (error instanceof ZodError) { - return error.issues.map((issue) => - `${issue.path.join(".") || ""}: ${issue.message}` - ).join("\n"); - } - - return error instanceof Error ? error.message : String(error); -}; - -interface ParsedValidateOptions { - json?: boolean; - path?: string; - spawnfileReport?: string; -} - -const parseOptionalValueFlag = ( - arg: string, - argv: readonly string[], - index: number, - flag: string, - errorMessage: string -): { error?: string; value?: string; consumed: boolean } => { - if (arg.startsWith(`${flag}=`)) { - return { consumed: true, value: arg.slice(flag.length + 1) }; - } - - if (arg !== flag) { - return { consumed: false }; - } - - const value = argv[index + 1]; - if (!value) { - return { error: errorMessage, consumed: false }; - } - return { consumed: true, value }; -}; - -const parseValidateArguments = (argv: readonly string[]): { - error?: string; - options?: ParsedValidateOptions; -} => { - const options: ParsedValidateOptions = {}; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - if (arg === "--json") { - options.json = true; - continue; - } - - const parsedReport = parseOptionalValueFlag( - arg, - argv, - index, - "--spawnfile-report", - "Missing value for --spawnfile-report" - ); - if (parsedReport.error) return { error: parsedReport.error }; - if (parsedReport.consumed) { - options.spawnfileReport = parsedReport.value; - if (arg === "--spawnfile-report") index += 1; - continue; - } - - if (arg.startsWith("-")) { - return { error: `Unknown flag ${arg}` }; - } - if (options.path !== undefined) { - return { error: `Unexpected positional argument ${arg}` }; - } - options.path = arg; - } - - if (!options.path) return { error: "Missing Simfile path" }; - return { options }; -}; - -const defaultRunId = (seed: string): string => seed.replace(/[^a-zA-Z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "run"; - -const toBindingDiagnostics = (warnings: string[]): BindingDiagnostic[] => - warnings.map((message) => ({ level: "warn", message })); - -const bindingWarningsFromReport = async ( - simfile: Simfile, - reportSource?: string -): Promise => { - if (!reportSource) { - return []; - } - return createBindingDiagnostics(simfile, await loadSpawnfileReport(reportSource)); -}; - -const hasErrorDiagnostic = (diagnostics: BindingDiagnostic[]): boolean => - diagnostics.some((diagnostic) => diagnostic.level === "error"); - -const printDiagnostics = (diagnostics: BindingDiagnostic[]): void => { - for (const diagnostic of diagnostics) { - const prefix = diagnostic.level === "error" ? "error" : "warning"; - process.stderr.write(`${prefix}: ${diagnostic.message}\n`); - } -}; +import { runSimfileCommand } from "./runCommand.js"; +import { runValidateCommand } from "./validateCommand.js"; export const runCli = async ( argv: readonly string[], dependencies: Readonly<{ runComposed?: LinkedComposedRunCommand }> = {}, ): Promise => { const [command, ...rest] = argv; - if (command === "--help" || command === "-h" || command === undefined) { - process.stdout.write(usage()); + process.stdout.write(simfileCliUsage()); return command === undefined ? 1 : 0; } - - if (command === "validate") { - const parsed = parseValidateArguments(rest); - if (parsed.error || !parsed.options?.path) { - if (parsed.error) process.stderr.write(`${parsed.error}\n`); - process.stderr.write(usage()); - return 1; - } - - const path = parsed.options.path; - try { - const source = await readFile(path, "utf8"); - const result = parseSimfileSource(source, { path }); - const diagnostics = [ - ...toBindingDiagnostics(result.warnings), - ...await bindingWarningsFromReport(result.simfile, parsed.options.spawnfileReport) - ]; - if (parsed.options.json) { - process.stdout.write(`${JSON.stringify({ - diagnostics, - ok: !hasErrorDiagnostic(diagnostics), - path - }, null, 2)}\n`); - } else { - printDiagnostics(diagnostics); - if (hasErrorDiagnostic(diagnostics)) { - process.stderr.write(`failed to validate ${path}\n`); - return 1; - } - process.stdout.write(`validated ${path}\n`); - } - return hasErrorDiagnostic(diagnostics) ? 1 : 0; - } catch (error) { - process.stderr.write(`${formatError(error)}\n`); - return 1; - } - } - - if (command === "run") { - let options; - try { - options = parseRunArguments(rest); - } catch (error) { - process.stderr.write(`${formatError(error)}\n`); - process.stderr.write(usage()); - return 1; - } - const simfilePath = options.path; - try { - const source = await readFile(simfilePath, "utf8"); - const result = parseSimfileSource(source, { path: simfilePath }); - const route = resolveSimfileRunRoute({ options, simfile: result.simfile, simfilePath }); - const diagnostics = [ - ...toBindingDiagnostics(result.warnings), - ...await bindingWarningsFromReport(result.simfile, options.spawnfileReport) - ]; - printDiagnostics(diagnostics); - if (hasErrorDiagnostic(diagnostics)) { - process.stderr.write("failed to validate simulation before running\n"); - return 1; - } - if (route.kind === "composed") { - return (dependencies.runComposed ?? runLinkedComposedCommand)({ - linked_spawnfile_path: route.linked_spawnfile_path, - options, - simfile: result.simfile, - simfile_path: simfilePath, - source_text: source, - }); - } - const seed = options.seed ?? result.simfile.clock.seed; - const runId = options.runId ?? defaultRunId(seed); - const outDir = resolve(options.outDir ?? `runs/${runId}`); - const clock = options.clock; - const run = await executeSimfileRun({ - actsPath: options.actsPath, - clock: clock === undefined ? () => new Date() : () => new Date(clock), - moltnetArtifact: options.moltnetArtifact, - outDir, - runId, - seed, - simfile: result.simfile, - simfilePath, - sourceText: source, - ticks: options.ticks! - }); - process.stdout.write(`wrote run ${runId} to ${run.outDir}\n`); - return 0; - } catch (error) { - process.stderr.write(`${formatError(error)}\n`); - return 1; - } - } - + if (command === "validate") return runValidateCommand(rest); + if (command === "run") return runSimfileCommand(rest, dependencies); if (command === "recover") { - try { - return await runRecoverCli(rest); - } catch (error) { - process.stderr.write(`${formatError(error)}\n`); + try { return await runRecoverCli(rest); } + catch (error) { + process.stderr.write(`${formatCliError(error)}\n`); return 1; } } - if (command === "observe") { - return runObserveCommand(rest, { formatError, usage }); - } - - if (command === "view") { - return runViewCommand(rest); + return runObserveCommand(rest, { formatError: formatCliError, usage: simfileCliUsage }); } - - if (command !== "validate" || !rest.length) { - process.stderr.write(usage()); - return 1; - } - - process.stderr.write(usage()); + if (command === "view") return runViewCommand(rest); + process.stderr.write(simfileCliUsage()); return 1; }; export const isCliEntrypoint = (moduleUrl: string, argvPath: string | undefined): boolean => { if (argvPath === undefined) return false; - try { - return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath); - } catch { - return false; - } + try { return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath); } + catch { return false; } }; if (isCliEntrypoint(import.meta.url, process.argv[1])) { diff --git a/src/cli/recover.test.ts b/src/cli/recover.test.ts index 102d0fa..e5b19f7 100644 --- a/src/cli/recover.test.ts +++ b/src/cli/recover.test.ts @@ -1,404 +1,56 @@ import assert from "node:assert/strict"; -import { execFile, spawn } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; import test from "node:test"; import { createComposedPhaseJournal, writeComposedPhaseJournal } from "../compose/journal.js"; -import { digestComposedJson } from "../compose/json.js"; -import { lifecycleOrganizationUpReceipt, lifecyclePreparation, lifecycleReadiness, lifecycleRequest } from "../compose/lifecycle.test-helper.js"; -import { createComposedRunHarness } from "../compose/run.test-helper.js"; -import { composedRecoveryCommand } from "../compose/receipt.js"; -import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; -import { builtRecoveryEffectCount, builtRecoveryProviderCommand, createForeignExecutionJournal, expectBuiltForeignJournalRejections, expectBuiltRecoveryArgumentRejections, expectBuiltRecoveryAuthorityFailure, failedBuiltRecovery, organizationExport, type BuiltRecovery } from "./recoverAuthority.test-helper.js"; +import { lifecycleRequest } from "../compose/lifecycle.test-helper.js"; +import { runRecoverCli } from "./recover.js"; -const execute = promisify(execFile); -const fixtureScript = (fixed: unknown): string => `#!/usr/bin/env node -import crypto from "node:crypto"; -import fs from "node:fs"; -const fixed=${JSON.stringify(fixed)}; -const argv=process.argv.slice(2); -fs.appendFileSync(fixed.logPath,JSON.stringify(argv)+"\\n"); -const canonical=(v)=>Array.isArray(v)?"["+v.map(canonical).join(",")+"]":v!==null&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+canonical(v[k])).join(",")+"}":JSON.stringify(v); -const digest=(domain,v)=>"sha256:"+crypto.createHash("sha256").update(domain+"\\0").update(canonical(v)).digest("hex"); -const seal=(domain,body)=>({...body,receipt_digest:digest(domain,body)}); -const output=(value)=>process.stdout.write(canonical(value)); -const crashWindow=(command,key)=>{ - if(fs.existsSync(fixed.beforeCrash)&&fs.readFileSync(fixed.beforeCrash,"utf8").trim()===command){fs.unlinkSync(fixed.beforeCrash);process.exit(86);} - const effects=fs.existsSync(fixed.effectState)?JSON.parse(fs.readFileSync(fixed.effectState,"utf8")):{}; - effects[command+":"+key]??=1; - fs.writeFileSync(fixed.effectState,JSON.stringify(effects)); - if(fs.existsSync(fixed.swapAfter)) { - const [mode,wanted]=fs.readFileSync(fixed.swapAfter,"utf8").trim().split(":"); - if(wanted===command){fs.unlinkSync(fixed.swapAfter);if(mode==="symlink"){fs.unlinkSync(fixed.journalPath);fs.symlinkSync(fixed.foreignJournal,fixed.journalPath);}else fs.renameSync(fixed.foreignJournal,fixed.journalPath);} - } - if(fs.existsSync(fixed.afterCrash)&&fs.readFileSync(fixed.afterCrash,"utf8").trim()===command){fs.unlinkSync(fixed.afterCrash);process.exit(87);} -}; -const requestFile=argv.at(-1); -const request=requestFile&&fs.existsSync(requestFile)?JSON.parse(fs.readFileSync(requestFile,"utf8")):undefined; -if(argv[0]==="target"&&argv[3]===fixed.hangCommand&&fs.existsSync(fixed.hangFlag)) { - fs.writeFileSync(fixed.childPid,String(process.pid)); - process.on("SIGTERM",()=>{}); - setInterval(()=>{},1000); - await new Promise(()=>{}); -} -if(argv[0]==="up") { crashWindow("up",argv.at(-1)); output(fixed.up); } -else if(argv[0]==="artifacts") { crashWindow("artifacts_export",argv.at(-1)); output(fixed.exportResult); } -else if(argv[0]==="down") { crashWindow("down",argv.at(-1)); output({version:"spawnfile.down-receipt.v1",deployment:"organization-unit",units_stopped:["organization-unit"],retained_volumes:[],errors:[]}); } -else if(argv[0]==="target") { - const command=argv[3]; - const selected={fingerprint:"sha256:"+"1".repeat(32),handle:"opaque_"+"6".repeat(16)}; - if(command==="prepare_composed_run") { - crashWindow(command,request.idempotency_key); - const {receipt_digest:_,...template}=fixed.preparation; - output(seal("spawnfile.composed-preparation.receipt.v1",{...template,request_digest:digest("spawnfile.composed-preparation.request.v1",request)})); - } else if(command==="query_world_readiness") { - output({readiness:fixed.readiness,readiness_digest:digest("spawnfile.target-world-readiness.document.v1",fixed.readiness),request_digest:digest("spawnfile.target-world-readiness.request.v1",request),run_id:request.run_id,version:"spawnfile.target-world-readiness-receipt.v1"}); - } else if(command==="attest_topology") { - output(seal("spawnfile.target-topology-receipt.v1",{descriptor_digest:request.descriptor_digest,handoff_scope:"organization_to_private_service",organization:{data_network_attachment:"exact",egress_policy:"egress_only"},request_digest:digest("spawnfile.target-topology-attestation.request.v1",request),run_id:request.run_id,selected_target:selected,service_discovery:"dns_only",version:"spawnfile.target-topology-receipt.v1",world_network:"private_internal",world_service:{data_network_attachment:"exactly_one",egress_policy:"none",published_ports:"none"}})); - } else if(command==="activate_topology") { - crashWindow(command,digest("spawnfile.target-topology-attestation.request.v1",request)); - const topology=seal("spawnfile.target-topology-receipt.v1",{descriptor_digest:request.descriptor_digest,handoff_scope:"organization_to_private_service",organization:{data_network_attachment:"exact",egress_policy:"egress_only"},request_digest:digest("spawnfile.target-topology-attestation.request.v1",request),run_id:request.run_id,selected_target:selected,service_discovery:"dns_only",version:"spawnfile.target-topology-receipt.v1",world_network:"private_internal",world_service:{data_network_attachment:"exactly_one",egress_policy:"none",published_ports:"none"}}); - const marker={bundle_digest:"sha256:"+"f".repeat(64),run_id:request.run_id,state:"activated",topology_receipt_digest:topology.receipt_digest,topology_request_digest:topology.request_digest,version:"spawnfile.world-service-activation.v1"}; - output(seal("spawnfile.target-topology-activation-receipt.v1",{activation_digest:digest("spawnfile.world-service-activation.v1",marker),bundle_digest:marker.bundle_digest,run_id:request.run_id,state:"activated",topology_receipt_digest:topology.receipt_digest,topology_request_digest:topology.request_digest,version:"spawnfile.target-topology-activation-receipt.v1"})); - } else if(command==="query_world_clock") { - const invalid=fs.existsSync(fixed.invalidClock)?fs.readFileSync(fixed.invalidClock,"utf8").trim():""; - const observedRun=invalid==="stale_run"?"run-stale-clock":request.run_id; - const clock=invalid==="never_tick"?{completed_tick:0,next_tick:1,state:"running"}:{completed_tick:1,next_tick:2,state:"running"}; - const observation={action_count:0,clock,run_id:observedRun,version:request.expected.document_version,world_instance_id:request.expected.world_instance_id}; - output(seal("spawnfile.target-world-clock-receipt.v1",{action_count:0,activation_digest:invalid==="activation_mismatch"?"sha256:"+"9".repeat(64):request.activation_digest,activation_receipt_digest:request.activation_receipt_digest,clock:observation.clock,observation_digest:digest("spawnfile.target-world-clock.observation.v1",observation),request_digest:digest("spawnfile.target-world-clock.request.v1",request),run_id:observedRun,topology_receipt_digest:invalid==="topology_forgery"?"sha256:"+"8".repeat(64):request.topology_receipt_digest,topology_request_digest:request.topology_request_digest,version:"spawnfile.target-world-clock-receipt.v1",world_instance_id:request.expected.world_instance_id,world_service_handle:request.world_service_handle})); - } else if(command==="snapshot_public_artifact") { - const terminal={outcome_digest:"sha256:"+"0".repeat(64),reason:"completed",run_id:request.run_id,terminal_tick:4,version:"simfile.composed-world-terminal-signal.v1"}; - const bytes=Buffer.from(canonical(terminal)); - output({artifact_id:request.artifact.id,content_base64:bytes.toString("base64"),content_digest:"sha256:"+crypto.createHash("sha256").update(bytes).digest("hex"),media_type:"application/json",request_digest:digest("spawnfile.target-public-artifact-snapshot.request.v1",request),run_id:request.run_id,size_bytes:bytes.length,version:"spawnfile.target-public-artifact-snapshot.v1"}); - } else { - const results={create_world_service:"opaque_"+"2".repeat(16),start_world_service:request.world_service_handle,attach_organization:"opaque_"+"4".repeat(16),stop_world_service:request.world_service_handle,export_evidence_volume:"opaque_"+"7".repeat(16),detach_organization:request.organization_attachment_handle,revoke_secret_bindings:request.secret_bindings_handle,cleanup_run:null}; - const operationHandles={create_world_service:"g",start_world_service:"h",attach_organization:"i",stop_world_service:"j",export_evidence_volume:"k",detach_organization:"l",revoke_secret_bindings:"m",cleanup_run:"n"}; - crashWindow(command,request.idempotency_key); - const evidenceMode=fs.existsSync(fixed.invalidEvidence)?fs.readFileSync(fixed.invalidEvidence,"utf8").trim():""; - const evidenceFiles=[{bytes:1,path:"actions/log.jsonl",sha256:"sha256:"+"a".repeat(64)},{bytes:2,path:"checkpoints/final.json",sha256:"sha256:"+"b".repeat(64)},{bytes:3,path:"projections/world.json",sha256:"sha256:"+"c".repeat(64)}]; - if(evidenceMode==="missing") evidenceFiles.shift(); - if(evidenceMode==="extra") evidenceFiles.push({bytes:1,path:"foreign/data",sha256:"sha256:"+"e".repeat(64)}); - const evidenceIndex=command==="export_evidence_volume"?{evidence_digest:"sha256:"+"d".repeat(64),export_handle:results[command],files:evidenceFiles,item_count:evidenceMode==="tamper"?99:evidenceFiles.length,labels:[],run_id:request.run_id,source:{evidence_volume_handle:evidenceMode==="source_mismatch"?"opaque_"+"9".repeat(16):request.evidence_volume_handle,state:"preserved"},state:"exported",version:"spawnfile.target-resource.export-index.v1"}:undefined; - output(seal("spawnfile.target-resource.receipt.v1",{cleanup_state:command==="cleanup_run"?"removed":"not_requested",descriptor_digest:request.descriptor_digest,...(evidenceIndex?{evidence_index:evidenceIndex}:{}),export_state:command==="export_evidence_volume"?"exported":"not_requested",labels:[],operation:command,operation_handle:"opaque_"+operationHandles[command].repeat(16),request_digest:digest("spawnfile.target-resource.request.v1",request),result_handle:results[command],resulting_revision:request.expected_revision+1,run_id:request.run_id,selected_target:selected,version:"spawnfile.target-resource.receipt.v1"})); - } -} else process.exitCode=2; -`; - -test("built recover survives every public mutation window and executes its emitted command", async () => { - await ensurePublicPackageBuild(path.resolve(".")); - const root = await mkdtemp(path.join(tmpdir(), "simfile-built-recover-")); +test("recover rejects legacy journals without reconstructing a target provider", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-recover-provider-")); try { - const request = lifecycleRequest({ run_id: "run-built-recovery" }); - const harness = createComposedRunHarness(request); + const request = lifecycleRequest(); const journalPath = path.join(root, "journal.json"); - const fakeSpawnfile = path.join(root, "spawnfile.mjs"); - const logPath = path.join(root, "spawnfile.log"); - const producerLog = path.join(root, "producer.log"); - const hangFlag = path.join(root, "hang"); - const invalidClock = path.join(root, "invalid-clock"); - const invalidEvidence = path.join(root, "invalid-evidence"); - const childPid = path.join(root, "child.pid"); - const beforeCrash = path.join(root, "before-crash"); - const afterCrash = path.join(root, "after-crash"); - const effectState = path.join(root, "effects.json"); - const foreignJournal = path.join(root, "foreign-journal.json"); - const producer = path.join(root, "target-config-producer.mjs"); - const swapAfter = path.join(root, "swap-after"); - const requestDigest = digestComposedJson("simfile.composed-run-request.v1", request); - const exportInvocation = `lci_${digestComposedJson( - "simfile.composed-organization-export-operation.v1", - { operation: "artifacts_export", request_digest: requestDigest }, - ).slice(7, 39)}`; - const execution = { + const journal = createComposedPhaseJournal(request, "2026-08-15T00:00:00.000Z", { configuration: { - organization_expectation: harness.configuration.organization_expectation, - readiness_expectation: harness.configuration.readiness_expectation, - terminal_tick: harness.configuration.terminal_tick, - topology_expectation: { - selected_target: harness.configuration.topology_expectation.selected_target, + organization_expectation: { + deployment_name: "organization-unit", member_engines: {}, + moltnet_release: { architecture: "amd64", asset_sha256: `sha256:${"1".repeat(64)}`, + release_version: "v1", source_revision: "a".repeat(40) }, + selected_target_receipt_digest: `sha256:${"2".repeat(64)}`, + unit_id: "organization-unit-container", world_binding_digest: request.organization.world_bindings_digest, }, + readiness_expectation: { artifact_digest: null, bundle_digest: request.world.bundle_digest, + capability_manifest_digests: [`sha256:${"3".repeat(64)}`], mechanics_sha256: `sha256:${"4".repeat(64)}`, + normalized_checkpoint_sha256: `sha256:${"5".repeat(64)}`, run_id: request.run_id, world_instance_id: "world" }, + terminal_tick: 1, + topology_expectation: { selected_target: { fingerprint: `sha256:${"6".repeat(32)}`, handle: `opaque_${"7".repeat(16)}` } }, }, provider: { - compiled_output_directory: path.join(root, "compiled"), - evidence_destination_directory: path.join(root, "evidence"), - evidence_mount_path: "/var/lib/simfile/evidence", - lifecycle_invocations: { - down: "lci_down_aaaaaaaaaaaa", export: exportInvocation, - up: "lci_up_aaaaaaaaaaaaaa", - }, - organization_handoff: { - env_file: path.join(root, "runtime.env"), - selected_target_receipt_file: path.join(root, "selected-target.json"), - world_bindings_file: path.join(root, "world-bindings.json"), - }, - organization_container_name: "organization-unit", - organization_image_tag: "organization-unit:run-built-recovery", - organization_path: path.join(root, "organization.yaml"), - spawnfile_bin: fakeSpawnfile, spawnfile_cwd: root, - target_config_producer: { - args: [request.target.selector], command: producer, - transport: "stdout_to_spawnfile_stdin", - }, - terminal_artifact: { - id: "terminal_receipt", max_bytes: 131_072, - path: "/tmp/spawnfile-public/terminal.json", - }, - world_readiness_port: 8080, + compiled_output_directory: path.join(root, "compiled"), evidence_destination_directory: path.join(root, "evidence"), + evidence_mount_path: "/var/lib/simfile/evidence", lifecycle_invocations: { down: `lci_${"a".repeat(16)}`, export: `lci_${"b".repeat(16)}`, up: `lci_${"c".repeat(16)}` }, + organization_handoff: { env_file: path.join(root, "env"), selected_target_receipt_file: path.join(root, "target"), world_bindings_file: path.join(root, "bindings") }, + organization_container_name: "organization-unit", organization_image_tag: "organization-unit:run", + organization_path: path.join(root, "Spawnfile"), spawnfile_bin: process.execPath, spawnfile_cwd: root, + spawnfile_executable_sha256: `sha256:${"8".repeat(64)}`, + terminal_artifact: { id: "terminal", max_bytes: 1024, path: "/tmp/spawnfile-public/terminal.json" }, world_readiness_port: 8080, }, - secret_bindings: [{ name: "provider_key", scope: "world", source_handle: "opaque_bbbbbbbbbbbbbbbb" }], + secret_bindings: [{ name: "world_key", scope: "world", source_handle: `opaque_${"9".repeat(16)}` }], version: "simfile.composed-execution.v1", - } as const; - await writeFile(producer, `#!/usr/bin/env node -import fs from "node:fs"; -fs.appendFileSync(${JSON.stringify(producerLog)}, process.argv[2] + "\\n"); -process.stdout.write('{}'); -`, { mode: 0o700 }); - await chmod(producer, 0o700); - await writeFile(fakeSpawnfile, fixtureScript({ - afterCrash, beforeCrash, childPid, effectState, foreignJournal, - invalidClock, invalidEvidence, swapAfter, - exportResult: organizationExport(request.run_id), hangCommand: "snapshot_public_artifact", - hangFlag, journalPath, logPath, - preparation: lifecyclePreparation(request), readiness: lifecycleReadiness(request), - up: lifecycleOrganizationUpReceipt(request.run_id, true), - }), { mode: 0o600 }); - const initialJournal = createComposedPhaseJournal( - request, "2026-08-07T00:00:00.000Z", execution, - ); - const authorityDigest = initialJournal.authority_digest; - await writeComposedPhaseJournal(journalPath, initialJournal); - const failedRecovery = (): Promise => failedBuiltRecovery({ authorityDigest, - cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), journalPath, - runId: request.run_id }); - const effectCount = (command: string): Promise => - builtRecoveryEffectCount(effectState, command); - const providerCommand = builtRecoveryProviderCommand; - const rejectSwap = async (command: string, mode: "replace" | "symlink"): Promise => { - const owned = await readFile(journalPath); - const foreignRequest = lifecycleRequest({ run_id: "run-foreign-journal" }); - await writeComposedPhaseJournal(foreignJournal, createForeignExecutionJournal( - foreignRequest, "2026-08-07T00:00:00.000Z", execution, - )); - const foreignBytes = await readFile(foreignJournal, "utf8"); - const before = (await readFile(logPath, "utf8")).trim().split("\n").length; - await writeFile(swapAfter, `${mode}:${command}\n`); - await expectBuiltRecoveryAuthorityFailure({ - authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), - journalPath, runId: request.run_id, - }); - const calls = (await readFile(logPath, "utf8")).trim().split("\n") - .slice(before).map((line) => JSON.parse(line) as string[]); - const commands = calls.map(providerCommand); - const attempted = commands.indexOf(command); - assert.notEqual(attempted, -1, `${mode}:${command}`); - assert.deepEqual(commands.slice(attempted), [command], `${mode}:${command}`); - assert.equal(await readFile(journalPath, "utf8"), foreignBytes); - await rm(journalPath, { force: true }); - await writeFile(journalPath, owned, { mode: 0o600 }); - await rm(foreignJournal, { force: true }); - }; - const failBefore = async (command: string): Promise => { - await writeFile(beforeCrash, `${command}\n`); - const receipt = await failedRecovery(); - assert.equal(receipt.status, "recovery_required"); - assert.equal(await effectCount(command), 0, command); - return receipt; - }; - const failAfter = async (command: string): Promise => { - await writeFile(afterCrash, `${command}\n`); - const receipt = await failedRecovery(); - assert.equal(receipt.status, "recovery_required"); - assert.equal(await effectCount(command), 1, command); - return receipt; - }; - const beforeTerminal = [ - "prepare_composed_run", "create_world_service", "start_world_service", - "up", "attach_organization", "activate_topology", - ] as const; - await failBefore(beforeTerminal[0]); - for (const [index, command] of beforeTerminal.entries()) { - await failAfter(command); - const next = beforeTerminal[index + 1]; - if (next !== undefined) await failBefore(next); - } - for (const invalid of [ - "never_tick", "stale_run", "topology_forgery", "activation_mismatch", - ] as const) { - await writeFile(invalidClock, `${invalid}\n`); - const rejected = await failedRecovery(); - assert.equal(rejected.status, "recovery_required", invalid); - const rejectedJournal = JSON.parse(await readFile(journalPath, "utf8")) as { - current_phase: string; entries: Array<{ phase: string }>; - }; - assert.equal(rejectedJournal.current_phase, "activated", invalid); - assert.equal(rejectedJournal.entries.some(({ phase }) => phase === "tick_1"), false, invalid); - } - await rm(invalidClock); - await writeFile(hangFlag, "hang\n"); - const child = spawn(process.execPath, [ - path.resolve("dist/cli/index.js"), "recover", "--journal", journalPath, - "--run-id", request.run_id, "--authority-digest", authorityDigest, - ], { cwd: path.resolve("."), stdio: ["ignore", "pipe", "pipe"] }); - let interruptedStdout = ""; - let interruptedStderr = ""; - child.stdout.on("data", (chunk: Buffer) => { interruptedStdout += chunk.toString("utf8"); }); - child.stderr.on("data", (chunk: Buffer) => { interruptedStderr += chunk.toString("utf8"); }); - for (let attempt = 0; attempt < 250; attempt += 1) { - if (await readFile(childPid, "utf8").then(() => true).catch(() => false)) break; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - const providerPidText = await readFile(childPid, "utf8").catch(() => ""); - assert.notEqual(providerPidText, "", `hung provider was not reached\nstdout=${interruptedStdout}\nstderr=${interruptedStderr}`); - const providerPid = Number(providerPidText); - const interruptedAt = Date.now(); - child.kill("SIGTERM"); - const exited = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( - (resolve) => child.once("close", (code, signal) => resolve({ code, signal })), - ); - const recovery = JSON.parse(interruptedStdout) as { - recovery_command: string; signal: string; status: string; - }; - assert.deepEqual(exited, { code: 143, signal: null }, interruptedStdout); - assert.equal(interruptedStderr, ""); - assert.ok(Date.now() - interruptedAt < 5_000); - assert.equal(recovery.status, "recovery_required"); - assert.equal(recovery.signal, "SIGTERM"); - assert.equal(recovery.recovery_command, - composedRecoveryCommand(journalPath, request.run_id, authorityDigest)); - assert.throws(() => process.kill(providerPid, 0), /ESRCH/u); - const interruptedCalls = await readFile(logPath, "utf8"); - assert.doesNotMatch(interruptedCalls, /export_evidence_volume|artifacts|cleanup_run/u); - await rm(hangFlag); - const afterTerminal = [ - "stop_world_service", "export_evidence_volume", "artifacts_export", - "detach_organization", "down", "revoke_secret_bindings", "cleanup_run", - ] as const; - await failBefore(afterTerminal[0]); - const crossRunRequest = lifecycleRequest({ run_id: "run-foreign-journal" }); - const modifiedDescriptor = lifecycleRequest({ descriptor_digest: `sha256:${"9".repeat(64)}` }); - await expectBuiltForeignJournalRejections({ - authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), - foreignJournals: [ - createForeignExecutionJournal(crossRunRequest, "2026-08-07T00:00:00.000Z", execution), - createForeignExecutionJournal(request, "2026-08-07T00:00:00.000Z", execution), - createForeignExecutionJournal(modifiedDescriptor, "2026-08-07T00:00:00.000Z", execution), - ], - foreignPath: foreignJournal, journalPath, providerLogs: [logPath, producerLog], - runId: request.run_id, }); - await rejectSwap(afterTerminal[0], "replace"); - let finalRecovery: BuiltRecovery | undefined = await failAfter(afterTerminal[0]); - await failBefore(afterTerminal[1]); - for (const invalid of ["missing", "extra", "tamper", "source_mismatch"] as const) { - await writeFile(invalidEvidence, `${invalid}\n`); - assert.equal((await failedRecovery()).status, "recovery_required", invalid); - const rejected = JSON.parse(await readFile(journalPath, "utf8")) as { - current_phase: string; entries: Array<{ phase: string }>; - }; - assert.equal(rejected.current_phase, "world_paused", invalid); - assert.equal(rejected.entries.some(({ phase }) => phase === "world_evidence_exported"), false); - } - await rm(invalidEvidence); - for (const [index, command] of afterTerminal.slice(1).entries()) { - await rejectSwap(command, index % 2 === 0 ? "symlink" : "replace"); - finalRecovery = await failAfter(command); - const next = afterTerminal[index + 2]; - if (next !== undefined) await failBefore(next); - } - const commandBin = path.join(root, "bin"); - await mkdir(commandBin); - const commandPath = path.join(commandBin, "simfile"); - await writeFile(commandPath, `#!/bin/sh -exec ${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("dist/cli/index.js"))} "$@" -`, { mode: 0o700 }); - await chmod(commandPath, 0o700); - let stdout: string; - let stderr: string; - try { - ({ stdout, stderr } = await execute("/bin/sh", ["-c", finalRecovery!.recovery_command], { - cwd: path.resolve("."), env: { ...process.env, PATH: `${commandBin}:${process.env.PATH ?? ""}` }, - timeout: 30_000, - })); - } catch (error) { - const failed = error as Error & { stderr?: string; stdout?: string }; - const log = await readFile(logPath, "utf8").catch(() => ""); - throw new Error(`${failed.message}\nstdout=${failed.stdout ?? ""}\nstderr=${failed.stderr ?? ""}\nlog=${log}`); - } - assert.equal(stderr, ""); - const receipt = JSON.parse(stdout) as { status: string; run_id: string }; - assert.equal(receipt.status, "completed"); - assert.equal(receipt.run_id, request.run_id); + await writeComposedPhaseJournal(journalPath, journal); + const before = await readFile(journalPath, "utf8"); + await assert.rejects(runRecoverCli([ + "--journal", journalPath, "--run-id", request.run_id, + "--authority-digest", journal.authority_digest, + ]), /legacy composed journal lacks the public target bootstrap capsule/u); + assert.equal(await readFile(journalPath, "utf8"), before); const stored = JSON.parse(await readFile(journalPath, "utf8")) as { current_phase: string; state: string; }; - assert.equal(stored.current_phase, "completed"); - assert.equal(stored.state, "complete"); - const calls = (await readFile(logPath, "utf8")).trim().split("\n").map( - (line) => JSON.parse(line) as string[], - ); - const targetCommands = calls.filter((call) => call[0] === "target").map((call) => call[3]); - for (const command of beforeTerminal.filter((value) => value !== "up")) { - assert.equal(targetCommands.filter((value) => value === command).length, 3, command); - } - for (const command of afterTerminal.filter((value) => - value !== "artifacts_export" && value !== "down")) { - assert.ok(targetCommands.filter((value) => value === command).length >= 3, command); - } - assert.equal(targetCommands.filter((value) => value === "query_world_readiness").length, 1); - assert.equal(targetCommands.filter((value) => value === "attest_topology").length, 1); - assert.equal(targetCommands.filter((value) => value === "query_world_clock").length, 5); - assert.equal(targetCommands.filter((value) => value === "snapshot_public_artifact").length, 2); - assert.ok(calls.filter((call) => call[0] === "up").length >= 3); - assert.ok(calls.filter((call) => call[0] === "artifacts").length >= 4); - assert.ok(calls.filter((call) => call[0] === "down").length >= 3); - assert.equal(calls.filter((call) => call[0] === "up") - .every((call) => call.at(-1) === "lci_up_aaaaaaaaaaaaaa"), true); - assert.equal(calls.filter((call) => call[0] === "artifacts") - .every((call) => call.at(-1) === exportInvocation), true); - assert.equal(calls.filter((call) => call[0] === "down") - .every((call) => call.at(-1) === "lci_down_aaaaaaaaaaaa"), true); - for (const command of [...beforeTerminal, ...afterTerminal]) { - assert.equal(await effectCount(command), 1, command); - } - assert.deepEqual((await readFile(producerLog, "utf8")).trim().split("\n"), - Array.from({ length: targetCommands.length }, () => request.target.selector)); - await expectBuiltRecoveryArgumentRejections({ - authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), - journalPath, providerLogs: [logPath, producerLog], runId: request.run_id, - }); - const callsBeforeRejections = calls.length; - const rejectJournal = async (candidate: string): Promise => { - await assert.rejects(execute(process.execPath, [ - path.resolve("dist/cli/index.js"), "recover", "--journal", candidate, - "--run-id", request.run_id, "--authority-digest", authorityDigest, - ], { cwd: path.resolve("."), timeout: 5_000 }), (error: unknown) => { - const failure = error as { code?: number; stdout?: string }; - return failure.code === 1 && failure.stdout === ""; - }); - }; - await rejectJournal(path.join(root, "missing.json")); - const malformed = path.join(root, "malformed.json"); - await writeFile(malformed, "{\n"); - await rejectJournal(malformed); - const secret = path.join(root, "secret.json"); - await writeFile(secret, '{"token":"token=must-not-load"}\n'); - await rejectJournal(secret); - const crossed = path.join(root, "crossed.json"); - const crossRun = JSON.parse(await readFile(journalPath, "utf8")) as Record; - const crossExecution = crossRun.execution as { - configuration: { readiness_expectation: { run_id: string } }; - }; - crossExecution.configuration.readiness_expectation.run_id = "run-foreign"; - const { journal_digest: _oldDigest, ...crossBody } = crossRun; - crossRun.journal_digest = digestComposedJson("simfile.composed-phase-journal.v1", crossBody); - await writeFile(crossed, `${JSON.stringify(crossRun)}\n`); - await rejectJournal(crossed); - assert.equal((await readFile(logPath, "utf8")).trim().split("\n").length, - callsBeforeRejections); - } finally { - await rm(root, { force: true, recursive: true }); - } + assert.equal(stored.current_phase, "requested"); + assert.equal(stored.state, "active"); + } finally { await rm(root, { force: true, recursive: true }); } }); diff --git a/src/cli/recover.ts b/src/cli/recover.ts index 0d28668..51d62d9 100644 --- a/src/cli/recover.ts +++ b/src/cli/recover.ts @@ -1,36 +1,64 @@ -import { composedRunConfiguration } from "../compose/execution.js"; -import { openComposedJournalSession } from "../compose/journalSession.js"; -import { parseComposedRecoveryArguments, recoverComposedRun } from "../compose/recovery.js"; -import { serializeComposedReceipt } from "../compose/receipt.js"; +import { + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + composedRunConfiguration, + openComposedJournalSession, + parseComposedRecoveryArguments, + recoverComposedRun, + serializeComposedReceipt, +} from "../compose/index.js"; import { createProductionComposedRunPorts } from "../spawnfile/productionPorts.js"; +import { finalizeComposedBootstrap } from "./composedBootstrapFinalize.js"; +import { reconstructComposedBootstrap } from "./composedBootstrapRecoverState.js"; +import { preserveComposedBootstrapFailure } from "./composedBootstrapRecovery.js"; -/** Restarts a composed lifecycle from its durable, nonsecret journal only. */ +const exitCode = (signal: "SIGINT" | "SIGTERM" | "failure" | "restart"): number => + signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1; + +/** Reconstructs provider authority solely from the durable secret-free capsule. */ export const runRecoverCli = async (argv: readonly string[]): Promise => { const parsed = parseComposedRecoveryArguments(argv); - const expectedAuthority = { - authority_digest: parsed.authority_digest, - run_id: parsed.run_id, - }; + const expectedAuthority = { authority_digest: parsed.authority_digest, + run_id: parsed.run_id }; const journalSession = await openComposedJournalSession( parsed.journal_path, expectedAuthority, ); - const journal = journalSession.current(); - if (journal.execution === undefined) { - throw new TypeError("composed journal does not contain production recovery inputs"); + const initial = journalSession.current(); + if (initial.version !== COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION + || initial.bootstrap === undefined) { + if (initial.execution === undefined) { + throw new TypeError("legacy unbound composed journals cannot be recovered"); + } + throw new TypeError( + "legacy composed journal lacks the public target bootstrap capsule required for recovery", + ); + } + let bootstrap; + try { + const prepared = await reconstructComposedBootstrap({ + capsule: initial.bootstrap, + journal_session: journalSession, + }); + bootstrap = await finalizeComposedBootstrap(prepared); + } catch (error) { + const recovery = await preserveComposedBootstrapFailure(journalSession, error); + process.stdout.write(serializeComposedReceipt(recovery.receipt)); + return exitCode(recovery.receipt.signal); } - const outcome = await recoverComposedRun({ - configuration: composedRunConfiguration(journal.execution), - expected_authority: expectedAuthority, - journal_path: parsed.journal_path, - journal_session: journalSession, - ports: createProductionComposedRunPorts({ - execution: journal.execution, + try { + const outcome = await recoverComposedRun({ + configuration: composedRunConfiguration(bootstrap.execution), + expected_authority: expectedAuthority, + journal_path: parsed.journal_path, journal_session: journalSession, - }), - }); - process.stdout.write(serializeComposedReceipt(outcome.receipt)); - if (outcome.receipt.status === "completed") return 0; - if (outcome.receipt.signal === "SIGINT") return 130; - if (outcome.receipt.signal === "SIGTERM") return 143; - return 1; + ports: createProductionComposedRunPorts({ + execution: bootstrap.execution, + journal_session: journalSession, + target_provider: bootstrap.target_provider, + }), + }); + process.stdout.write(serializeComposedReceipt(outcome.receipt)); + return outcome.receipt.status === "completed" ? 0 : exitCode(outcome.receipt.signal); + } finally { + bootstrap.target_provider.close(); + } }; diff --git a/src/cli/recoverAuthority.test-helper.ts b/src/cli/recoverAuthority.test-helper.ts index 5660763..9b0bb90 100644 --- a/src/cli/recoverAuthority.test-helper.ts +++ b/src/cli/recoverAuthority.test-helper.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; import { promisify } from "node:util"; import { createComposedPhaseJournal } from "../compose/journal.js"; import type { ComposedPhaseJournal } from "../compose/journal.js"; import { parseComposedExecution } from "../compose/execution.js"; +import { digestComposedJson } from "../compose/json.js"; import type { ComposedRunRequest } from "../compose/request.js"; const execute = promisify(execFile); @@ -167,3 +169,42 @@ export const expectBuiltRecoveryArgumentRejections = async (input: Readonly<{ } assert.deepEqual(await Promise.all(input.providerLogs.map(optionalBytes)), logsBefore); }; + +export const expectBuiltRecoveryFileRejections = async (input: Readonly<{ + authorityDigest: string; + cliPath: string; + cwd: string; + journalPath: string; + logPath: string; + root: string; + runId: string; +}>): Promise => { + const callsBefore = (await readFile(input.logPath, "utf8")).trim().split("\n").length; + const rejectJournal = async (candidate: string): Promise => { + await assert.rejects(execute(process.execPath, [ + input.cliPath, "recover", "--journal", candidate, "--run-id", input.runId, + "--authority-digest", input.authorityDigest, + ], { cwd: input.cwd, timeout: 5_000 }), (error: unknown) => { + const failure = error as { code?: number; stdout?: string }; + return failure.code === 1 && failure.stdout === ""; + }); + }; + await rejectJournal(path.join(input.root, "missing.json")); + const malformed = path.join(input.root, "malformed.json"); + await writeFile(malformed, "{\n"); + await rejectJournal(malformed); + const secret = path.join(input.root, "secret.json"); + await writeFile(secret, '{"token":"token=must-not-load"}\n'); + await rejectJournal(secret); + const crossed = path.join(input.root, "crossed.json"); + const crossRun = JSON.parse(await readFile(input.journalPath, "utf8")) as Record; + const crossExecution = crossRun.execution as { + configuration: { readiness_expectation: { run_id: string } }; + }; + crossExecution.configuration.readiness_expectation.run_id = "run-foreign"; + const { journal_digest: _oldDigest, ...crossBody } = crossRun; + crossRun.journal_digest = digestComposedJson("simfile.composed-phase-journal.v1", crossBody); + await writeFile(crossed, `${JSON.stringify(crossRun)}\n`); + await rejectJournal(crossed); + assert.equal((await readFile(input.logPath, "utf8")).trim().split("\n").length, callsBefore); +}; diff --git a/src/cli/runArguments.test.ts b/src/cli/runArguments.test.ts index 79c08fc..ba5e05d 100644 --- a/src/cli/runArguments.test.ts +++ b/src/cli/runArguments.test.ts @@ -13,6 +13,7 @@ describe("run argument matrix", () => { ["--clock", "2026-08-07T00:00:00Z", "clock", "2026-08-07T00:00:00Z"], ["--moltnet-artifact", "transcript", "moltnetArtifact", "transcript"], ["--spawnfile-report", "report.json", "spawnfileReport", "report.json"], + ["--mode", "lifecycle-replay-smoke", "composedMode", "lifecycle-replay-smoke"], ] as const); for (const [flag, value, key, expected] of values) { @@ -44,6 +45,6 @@ describe("run argument matrix", () => { assert.throws(() => parseRunArguments([ "Simfile", "--moltnet-artifact=messages", ]), /Invalid/u); + assert.throws(() => parseRunArguments(["Simfile", "--mode=dry-run"]), /Invalid/u); }); }); - diff --git a/src/cli/runArguments.ts b/src/cli/runArguments.ts index d42927c..d69183b 100644 --- a/src/cli/runArguments.ts +++ b/src/cli/runArguments.ts @@ -1,8 +1,11 @@ import type { MoltnetArtifactKind } from "../runtime/trace.js"; +export type ComposedCommandMode = "live" | "lifecycle-replay-smoke"; + export interface ParsedRunOptions { readonly actsPath?: string; readonly clock?: string; + readonly composedMode?: ComposedCommandMode; readonly local: boolean; readonly moltnetArtifact?: MoltnetArtifactKind; readonly outDir?: string; @@ -10,6 +13,7 @@ export interface ParsedRunOptions { readonly runId?: string; readonly seed?: string; readonly spawnfileReport?: string; + readonly targetContext?: string; readonly ticks?: number; readonly view: boolean; } @@ -30,6 +34,7 @@ const valueFlags = Object.freeze({ "--run-id": "runId", "--seed": "seed", "--spawnfile-report": "spawnfileReport", + "--context": "targetContext", } as const); const flagValue = ( @@ -92,6 +97,18 @@ export const parseRunArguments = (argv: readonly string[]): ParsedRunOptions => index += artifact.consumed; continue; } + const mode = flagValue(arg, argv, index, "--mode"); + if (mode !== undefined) { + if (options.composedMode !== undefined) { + throw new TypeError("Duplicate flag --mode"); + } + if (mode.value !== "live" && mode.value !== "lifecycle-replay-smoke") { + throw new TypeError("Invalid value for --mode"); + } + options.composedMode = mode.value; + index += mode.consumed; + continue; + } let matched = false; for (const [flag, key] of Object.entries(valueFlags) as Array< [keyof typeof valueFlags, (typeof valueFlags)[keyof typeof valueFlags]] @@ -99,6 +116,9 @@ export const parseRunArguments = (argv: readonly string[]): ParsedRunOptions => const parsed = flagValue(arg, argv, index, flag); if (parsed === undefined) continue; if (options[key] !== undefined) throw new TypeError(`Duplicate flag ${flag}`); + if (key === "targetContext" && !/^[a-z][a-z0-9_-]{0,63}$/u.test(parsed.value!)) { + throw new TypeError("Invalid value for --context"); + } options[key] = parsed.value; index += parsed.consumed; matched = true; diff --git a/src/cli/runCommand.ts b/src/cli/runCommand.ts new file mode 100644 index 0000000..6cdeeda --- /dev/null +++ b/src/cli/runCommand.ts @@ -0,0 +1,68 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { executeSimfileRun } from "../run/run-driver.js"; +import { parseSimfileSource } from "../schema/index.js"; +import { runLinkedComposedCommand, type LinkedComposedRunCommand } from + "./composedRunCommand.js"; +import { + bindingDiagnostics, + formatCliError, + hasErrorDiagnostic, + printDiagnostics, + simfileCliUsage, +} from "./cliShared.js"; +import { parseRunArguments } from "./runArguments.js"; +import { resolveSimfileRunRoute } from "./runRoute.js"; + +const defaultRunId = (seed: string): string => seed.replace(/[^a-zA-Z0-9_.-]+/gu, "-") + .replace(/^-+|-+$/gu, "") || "run"; + +export const runSimfileCommand = async ( + argv: readonly string[], + dependencies: Readonly<{ runComposed?: LinkedComposedRunCommand }>, +): Promise => { + let options; + try { options = parseRunArguments(argv); } + catch (error) { + process.stderr.write(`${formatCliError(error)}\n`); + process.stderr.write(simfileCliUsage()); + return 1; + } + const simfilePath = options.path; + try { + const source = await readFile(simfilePath, "utf8"); + const result = parseSimfileSource(source, { path: simfilePath }); + const route = resolveSimfileRunRoute({ options, simfile: result.simfile, simfilePath }); + const diagnostics = await bindingDiagnostics( + result.simfile, result.warnings, options.spawnfileReport, + ); + printDiagnostics(diagnostics); + if (hasErrorDiagnostic(diagnostics)) { + process.stderr.write("failed to validate simulation before running\n"); + return 1; + } + if (route.kind === "composed") { + return await (dependencies.runComposed ?? runLinkedComposedCommand)({ + linked_spawnfile_path: route.linked_spawnfile_path, + options, simfile: result.simfile, simfile_path: simfilePath, source_text: source, + }); + } + const seed = options.seed ?? result.simfile.clock.seed; + const runId = options.runId ?? defaultRunId(seed); + const clock = options.clock; + const run = await executeSimfileRun({ + actsPath: options.actsPath, + clock: clock === undefined ? () => new Date() : () => new Date(clock), + moltnetArtifact: options.moltnetArtifact, + outDir: resolve(options.outDir ?? `runs/${runId}`), + runId, seed, simfile: result.simfile, simfilePath, sourceText: source, + ticks: options.ticks!, + }); + process.stdout.write(`wrote run ${runId} to ${run.outDir}\n`); + return 0; + } catch (error) { + process.stderr.write(`${formatCliError(error)}\n`); + return 1; + } +}; diff --git a/src/cli/runRoute.test.ts b/src/cli/runRoute.test.ts index cffd876..136877a 100644 --- a/src/cli/runRoute.test.ts +++ b/src/cli/runRoute.test.ts @@ -24,6 +24,9 @@ describe("run routing", () => { assert.deepEqual(route(true, []), { kind: "composed", linked_spawnfile_path: "/work/organization/Spawnfile", }); + assert.deepEqual(route(true, ["--mode", "lifecycle-replay-smoke"]), { + kind: "composed", linked_spawnfile_path: "/work/organization/Spawnfile", + }); assert.deepEqual(route(true, ["--local", "--ticks", "2"]), { kind: "local", linked_spawnfile_path: "/work/organization/Spawnfile", }); @@ -42,6 +45,7 @@ describe("run routing", () => { assert.throws(() => route(false, []), /require --ticks/u); assert.throws(() => route(true, ["--local"]), /require --ticks/u); assert.throws(() => route(false, ["--ticks", "1", "--view"]), /reject --view/u); + assert.throws(() => route(false, ["--ticks", "1", "--mode", "live"]), + /reject --mode/u); }); }); - diff --git a/src/cli/runRoute.ts b/src/cli/runRoute.ts index 1d1379b..a8c2908 100644 --- a/src/cli/runRoute.ts +++ b/src/cli/runRoute.ts @@ -38,10 +38,15 @@ export const resolveSimfileRunRoute = (input: Readonly<{ if (input.options.ticks === undefined) { throw new TypeError("Local runs require --ticks"); } + if (input.options.targetContext !== undefined) { + throw new TypeError("Local runs reject --context"); + } + if (input.options.composedMode !== undefined) { + throw new TypeError("Local runs reject --mode"); + } if (input.options.view) { throw new TypeError("Local runs reject --view"); } return Object.freeze({ kind: "local", ...(linked === undefined ? {} : { linked_spawnfile_path: linked }) }); }; - diff --git a/src/cli/validateCommand.ts b/src/cli/validateCommand.ts new file mode 100644 index 0000000..668d5df --- /dev/null +++ b/src/cli/validateCommand.ts @@ -0,0 +1,66 @@ +import { readFile } from "node:fs/promises"; + +import { parseSimfileSource } from "../schema/index.js"; +import { + bindingDiagnostics, + formatCliError, + hasErrorDiagnostic, + printDiagnostics, + simfileCliUsage, +} from "./cliShared.js"; + +interface ParsedValidateOptions { + json?: boolean; + path?: string; + spawnfileReport?: string; +} + +const parseValidateArguments = (argv: readonly string[]): { + error?: string; + options?: ParsedValidateOptions; +} => { + const options: ParsedValidateOptions = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--json") { options.json = true; continue; } + if (arg === "--spawnfile-report" || arg.startsWith("--spawnfile-report=")) { + const value = arg === "--spawnfile-report" ? argv[++index] + : arg.slice("--spawnfile-report=".length); + if (!value) return { error: "Missing value for --spawnfile-report" }; + options.spawnfileReport = value; + continue; + } + if (arg.startsWith("-")) return { error: `Unknown flag ${arg}` }; + if (options.path !== undefined) return { error: `Unexpected positional argument ${arg}` }; + options.path = arg; + } + return options.path === undefined ? { error: "Missing Simfile path" } : { options }; +}; + +export const runValidateCommand = async (argv: readonly string[]): Promise => { + const parsed = parseValidateArguments(argv); + if (parsed.error || !parsed.options?.path) { + if (parsed.error) process.stderr.write(`${parsed.error}\n`); + process.stderr.write(simfileCliUsage()); + return 1; + } + const path = parsed.options.path; + try { + const result = parseSimfileSource(await readFile(path, "utf8"), { path }); + const diagnostics = await bindingDiagnostics( + result.simfile, result.warnings, parsed.options.spawnfileReport, + ); + const ok = !hasErrorDiagnostic(diagnostics); + if (parsed.options.json) { + process.stdout.write(`${JSON.stringify({ diagnostics, ok, path }, null, 2)}\n`); + } else { + printDiagnostics(diagnostics); + if (!ok) process.stderr.write(`failed to validate ${path}\n`); + else process.stdout.write(`validated ${path}\n`); + } + return ok ? 0 : 1; + } catch (error) { + process.stderr.write(`${formatCliError(error)}\n`); + return 1; + } +}; diff --git a/src/compose/AGENTS.md b/src/compose/AGENTS.md index aef82ff..855a208 100644 --- a/src/compose/AGENTS.md +++ b/src/compose/AGENTS.md @@ -18,16 +18,21 @@ never coupled to agent actions. - `projectBinding.ts` — host-only fixture declaration seam for a runnable world, credentials, evidence mappings, and mechanics-only replay adapter. - `receipt.ts` — strict terminal and recovery receipt parsers/builders. -- `journal.ts` — monotonic phase journal, exact restore, and durable atomic store. +- `journal.ts` is the journal barrel; `journalSchema.ts`, `journalValidation.ts`, + `journalGenesis.ts`, `journalTransitions.ts`, and `journalStore.ts` keep the + monotonic schema, exact restore, transitions, and durable store separate. - `journalSession.ts` — pinned file identity, safe open, and expected-prior atomic replacement. - `startup-world.ts` — prepared-resource to paused world-only readiness sequence. -- `startup-organization.ts` — organization-second startup and exact binding/readiness proof. +- `startup-organization.ts` — organization-second startup; its exact + binding/readiness receipt lives in `startupOrganizationReceipt.ts`. - `activation.ts` — topology attestation and single-use clock release. -- `supervision.ts` — world/service-only tick and terminal supervision. +- `supervision.ts` — world/service-only tick and terminal supervision; + `supervisionTimeout.ts` owns bounded cancellation and quiescence. - `finalize-world.ts` — pause/flush/hash/export of world evidence before cleanup. - `finalize-organization.ts` — public Spawnfile artifact export and reconciliation. - `cleanup.ts` — evidence-gated, receipt-owned teardown and revocation. -- `recovery.ts` — signal-safe interruption, durable recovery receipts, and resume. +- `recovery.ts` — signal-safe interruption, durable recovery receipts, and + resume; `recoveryCommand.ts` owns the exact authority-bound CLI arguments. - `run.ts` — the one high-level operation that composes these phase functions. - `runRecord.ts` — the generic role-complete, exact-hash staging inventory and atomic live-to-sealed run-directory promotion; related artifact groups are @@ -49,7 +54,8 @@ never coupled to agent actions. derived frame track and provenance ledger as one artifact group. - `index.ts` — named public barrel. -Tests remain beside the boundary they prove. Production files stay below 400 +Tests remain beside the boundary they prove. Changed production files stay at +or below 200 lines. All journal and receipt values are secret-free, versioned, correlated to one run, and additive only through a new versioned contract. diff --git a/src/compose/bootstrapAuthority.ts b/src/compose/bootstrapAuthority.ts new file mode 100644 index 0000000..288b99a --- /dev/null +++ b/src/compose/bootstrapAuthority.ts @@ -0,0 +1,121 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; + +export const COMPOSED_BOOTSTRAP_CAPSULE_VERSION = + "simfile.composed-bootstrap-capsule.v2" as const; +export const COMPOSED_BOOTSTRAP_BINDING_VERSION = + "simfile.composed-bootstrap-binding.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const absolutePath = z.string().max(4_096).refine((value) => + path.isAbsolute(value) && path.normalize(value) === value + && value !== path.parse(value).root); +const safeEnvironment = z.record( + z.string().regex(/^[A-Z][A-Z0-9_]{1,127}$/u), + z.string().min(1).max(4_096), +).refine((value) => Object.keys(value).length <= 16); + +export const composedBootstrapCapsuleSchema = z.object({ + command_mode: z.enum(["live", "lifecycle-replay-smoke"]), + paths: z.object({ + compiled: absolutePath, + env_file: absolutePath, + grants_file: absolutePath, + journal: absolutePath, + organization_evidence: absolutePath, + organization_path: absolutePath, + preflight_report: absolutePath, + prepared_plan: absolutePath, + run: absolutePath, + selected_target_file: absolutePath, + simfile: absolutePath, + support_root: absolutePath, + world_bindings_file: absolutePath, + world_evidence: absolutePath, + world_evidence_archive: absolutePath, + }).strict(), + project: z.object({ + compile_fingerprint: z.string().min(1).max(256), + descriptor_digest: digest, + preflight_report_digest: digest, + seed: z.string().min(1).max(4_096), + simfile_source_digest: digest, + spawnfile_source_digest: digest, + }).strict(), + provider: z.object({ + base_image: z.string().min(1).max(512), + capability_contract_digest: digest, + context: identifier, + docker_command: z.string().min(1).max(1_024), + process_environment: safeEnvironment, + spawnfile_bin: absolutePath, + spawnfile_cwd: absolutePath, + spawnfile_executable_sha256: digest, + spawnfile_package_version: z.literal("0.1.17"), + }).strict(), + run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u), + version: z.literal(COMPOSED_BOOTSTRAP_CAPSULE_VERSION), +}).strict(); + +const helper = z.object({ + digest, + handle: z.string().regex(/^opaque_[a-z0-9]{16,64}$/u), + version: z.literal("spawnfile.target-evidence-export-helper.prepared.v1"), +}).strict(); +const selectedTarget = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), + handle: z.string().regex(/^opaque_[a-z0-9]{16,64}$/u), +}).strict(); + +export const composedBootstrapBindingSchema = z.object({ + bootstrap_authority_digest: digest, + bootstrap_digest: digest, + execution_digest: digest, + receipt_digest: digest, + request_digest: digest, + run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u), + target: z.object({ + context: identifier, + prepared_evidence_helper: helper, + selected_target: selectedTarget, + selected_target_receipt_digest: digest, + target_config_digest: digest, + }).strict(), + version: z.literal(COMPOSED_BOOTSTRAP_BINDING_VERSION), +}).strict(); + +export type ComposedBootstrapCapsule = z.infer; +export type ComposedBootstrapBinding = z.infer; + +export const parseComposedBootstrapCapsule = (raw: unknown): ComposedBootstrapCapsule => { + assertSecretFreeComposedJson(raw); + return Object.freeze(composedBootstrapCapsuleSchema.parse(raw)); +}; + +export const composedBootstrapDigest = (raw: unknown): `sha256:${string}` => + digestComposedJson(COMPOSED_BOOTSTRAP_CAPSULE_VERSION, parseComposedBootstrapCapsule(raw)); + +export const parseComposedBootstrapBinding = (raw: unknown): ComposedBootstrapBinding => { + assertSecretFreeComposedJson(raw); + const value = composedBootstrapBindingSchema.parse(raw); + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson(COMPOSED_BOOTSTRAP_BINDING_VERSION, body)) { + throw new TypeError("composed bootstrap binding digest is invalid"); + } + return Object.freeze(value); +}; + +export const createComposedBootstrapBinding = ( + body: Omit, +): ComposedBootstrapBinding => parseComposedBootstrapBinding({ + ...body, + receipt_digest: digestComposedJson(COMPOSED_BOOTSTRAP_BINDING_VERSION, { + ...body, + version: COMPOSED_BOOTSTRAP_BINDING_VERSION, + }), + version: COMPOSED_BOOTSTRAP_BINDING_VERSION, +}); diff --git a/src/compose/bootstrapJournal.test.ts b/src/compose/bootstrapJournal.test.ts new file mode 100644 index 0000000..9f9b288 --- /dev/null +++ b/src/compose/bootstrapJournal.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bindComposedJournalExecution, + createBootstrapComposedPhaseJournal, +} from "./journal.js"; +import { createComposedBootstrapBinding, composedBootstrapDigest } from + "./bootstrapAuthority.js"; +import { COMPOSED_EXECUTION_VERSION } from "./execution.js"; +import { digestComposedJson } from "./json.js"; +import { + journalBootstrapOperationIntent, + journalBootstrapOperationObservation, +} from "./bootstrapOperationJournal.js"; + +const sha = (character: string): `sha256:${string}` => + `sha256:${character.repeat(64).slice(0, 64)}`; +const request = { + descriptor_digest: sha("a"), mode: "live", + organization: { artifact_digest: sha("b"), source_digest: sha("c"), + world_bindings_digest: sha("d") }, + required_world_capabilities: ["simfile.world-decision-claim.v1"], + run_id: "run-bootstrap", source_digest: sha("e"), + target: { auth_profile: "scripted-no-model-auth", selector: "local_test" }, + version: "simfile.composed-run-request.v1", + world: { artifact_manifest_digest: sha("f"), bundle_digest: sha("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1" }, +} as const; +const capsule = { + command_mode: "lifecycle-replay-smoke", + paths: { compiled: "/tmp/bootstrap/compiled", env_file: "/tmp/bootstrap/env", + grants_file: "/tmp/bootstrap/grants", journal: "/tmp/bootstrap/journal.json", + organization_evidence: "/tmp/bootstrap/org-evidence", + organization_path: "/tmp/project/Spawnfile", + preflight_report: "/tmp/bootstrap/preflight-report.json", + prepared_plan: "/tmp/bootstrap/plan", + run: "/tmp/run", selected_target_file: "/tmp/bootstrap/selected", + simfile: "/tmp/project/Simfile", support_root: "/tmp/bootstrap", + world_bindings_file: "/tmp/bootstrap/bindings", + world_evidence: "/tmp/bootstrap/world-evidence", + world_evidence_archive: "/tmp/bootstrap/world.tar" }, + project: { compile_fingerprint: "sf1:aaaaaaaaaaaa", descriptor_digest: sha("a"), + preflight_report_digest: sha("0"), seed: "seed", + simfile_source_digest: sha("e"), spawnfile_source_digest: sha("c") }, + provider: { base_image: "node:22-bookworm-slim", + capability_contract_digest: sha("2"), context: "local_test", docker_command: "docker", + process_environment: { NOOPOLIS_RUN_ID: "run-bootstrap", + SPAWNFILE_HOME: "/tmp/bootstrap/auth" }, + spawnfile_bin: "/tmp/install/spawnfile", spawnfile_cwd: "/tmp/project", + spawnfile_executable_sha256: sha("3"), spawnfile_package_version: "0.1.17" }, + run_id: "run-bootstrap", version: "simfile.composed-bootstrap-capsule.v2", +} as const; +const selected = { fingerprint: `sha256:${"4".repeat(32)}`, + handle: "opaque_aaaaaaaaaaaaaaaa" } as const; +const helper = { digest: sha("5"), handle: "opaque_bbbbbbbbbbbbbbbb", + version: "spawnfile.target-evidence-export-helper.prepared.v1" } as const; +const execution = { + configuration: { organization_expectation: { deployment_name: "organization_unit", + member_engines: { smoke: "scripted" }, moltnet_release: { architecture: "amd64", + asset_sha256: sha("6"), release_version: "v1", source_revision: "7".repeat(40) }, + selected_target_receipt_digest: sha("8"), unit_id: "organization_unit_container", + world_binding_digest: request.organization.world_bindings_digest }, + readiness_expectation: { artifact_digest: sha("9"), bundle_digest: request.world.bundle_digest, + capability_manifest_digests: [sha("a")], mechanics_sha256: sha("b"), + normalized_checkpoint_sha256: sha("c"), run_id: request.run_id, + world_instance_id: "bootstrap-world" }, terminal_tick: 4, + topology_expectation: { selected_target: selected } }, + provider: { compiled_output_directory: "/tmp/bootstrap/compiled", + evidence_destination_directory: "/tmp/bootstrap/evidence", + evidence_mount_path: "/var/lib/simfile/evidence", + lifecycle_invocations: { down: "lci_down_aaaaaaaaaaaa", export: "lci_export_aaaaaaaaaa", + up: "lci_up_aaaaaaaaaaaaaa" }, organization_handoff: { + env_file: "/tmp/bootstrap/env", selected_target_receipt_file: "/tmp/bootstrap/selected", + world_bindings_file: "/tmp/bootstrap/bindings" }, + organization_container_name: "organization_unit", organization_image_tag: "organization:run", + organization_path: "/tmp/project/Spawnfile", spawnfile_bin: "/tmp/install/spawnfile", + spawnfile_cwd: "/tmp/project", spawnfile_executable_sha256: sha("3"), + target_resolution: { base_image: { config_digest: sha("d"), reference: "node:22-bookworm-slim" }, + context: "local_test", endpoint_transport: "unix", platform: { architecture: "amd64", os: "linux" }, + prepared_evidence_helper: helper, target_config_digest: sha("e"), + version: "spawnfile.target-config-resolution.v1" }, terminal_artifact: { + id: "terminal", max_bytes: 131072, path: "/tmp/spawnfile-public/terminal.json" }, + world_readiness_port: 4070 }, secret_bindings: [{ name: "world_token", scope: "world", + source_handle: "opaque_cccccccccccccccc" }], version: COMPOSED_EXECUTION_VERSION, +} as const; + +test("v2 bootstrap binds execution once without changing authority", () => { + const initial = createBootstrapComposedPhaseJournal(request, capsule, + "2026-08-16T00:00:00.000Z"); + let prepared = initial; + for (const kind of ["resolve_target_config", "select_target", + "prepare_container_bundle", "provision_credentials"] as const) { + prepared = journalBootstrapOperationIntent(prepared, kind, { kind }); + const operation = prepared.bootstrap_operations!.at(-1)!; + prepared = journalBootstrapOperationObservation( + prepared, operation.operation_id, "completed", { kind }, + ); + } + const binding = createComposedBootstrapBinding({ + bootstrap_authority_digest: prepared.authority_digest, + bootstrap_digest: composedBootstrapDigest(capsule), + execution_digest: digestComposedJson(COMPOSED_EXECUTION_VERSION, execution), + request_digest: prepared.request_digest, run_id: request.run_id, + target: { context: "local_test", prepared_evidence_helper: helper, + selected_target: selected, selected_target_receipt_digest: sha("8"), + target_config_digest: sha("e") }, + }); + const bound = bindComposedJournalExecution(prepared, execution, binding); + assert.equal(bound.authority_digest, initial.authority_digest); + assert.deepEqual(bound.bootstrap_binding, binding); + assert.throws(() => bindComposedJournalExecution(bound, execution, binding), /binding/u); +}); + +test("v2 bootstrap operations are ordered, single-flight, and required for binding", () => { + const initial = createBootstrapComposedPhaseJournal( + request, capsule, "2026-08-16T00:00:00.000Z", + ); + assert.throws(() => journalBootstrapOperationIntent( + initial, "select_target", { kind: "select_target" }, + ), /intent is invalid/u); + const resolving = journalBootstrapOperationIntent( + initial, "resolve_target_config", { kind: "resolve_target_config" }, + ); + assert.throws(() => journalBootstrapOperationIntent( + resolving, "select_target", { kind: "select_target" }, + ), /intent is invalid/u); + assert.throws(() => bindComposedJournalExecution(initial, execution, {})); +}); diff --git a/src/compose/bootstrapOperationContract.ts b/src/compose/bootstrapOperationContract.ts new file mode 100644 index 0000000..151bb0f --- /dev/null +++ b/src/compose/bootstrapOperationContract.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); + +export const COMPOSED_BOOTSTRAP_OPERATION_KINDS = Object.freeze([ + "resolve_target_config", + "select_target", + "prepare_container_bundle", + "provision_credentials", + "prepare_composed_run", +] as const); + +export const composedBootstrapOperationSchema = z.object({ + kind: z.enum(COMPOSED_BOOTSTRAP_OPERATION_KINDS), + operation_id: digest, + recorded_at: z.string().datetime({ offset: true }), + request: z.record(z.string(), z.unknown()), + request_digest: digest, + sequence: z.number().int().min(0).max(15), + state: z.enum([ + "ambiguous", + "completed", + "intent_durable", + "lookup_required", + "not_applied", + "pending", + ]), + receipt: z.record(z.string(), z.unknown()).optional(), +}).strict(); + +export type ComposedBootstrapOperation = z.infer< + typeof composedBootstrapOperationSchema +>; diff --git a/src/compose/bootstrapOperationJournal.ts b/src/compose/bootstrapOperationJournal.ts new file mode 100644 index 0000000..1b27f58 --- /dev/null +++ b/src/compose/bootstrapOperationJournal.ts @@ -0,0 +1,83 @@ +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { digestComposedJson } from "./json.js"; +import { + COMPOSED_BOOTSTRAP_OPERATION_KINDS, + type ComposedBootstrapOperation, +} from "./bootstrapOperationContract.js"; + +export type BootstrapOperationKind = + (typeof COMPOSED_BOOTSTRAP_OPERATION_KINDS)[number]; +export type BootstrapOperationState = ComposedBootstrapOperation["state"]; + +const requestDigest = ( + kind: BootstrapOperationKind, + request: Readonly>, +): `sha256:${string}` => digestComposedJson( + "simfile.composed-bootstrap-operation-request.v1", { kind, request }, +); + +const replace = ( + journal: ComposedPhaseJournal, + bootstrap_operations: readonly Record[], +): ComposedPhaseJournal => { + const { journal_digest: _digest, ...body } = journal; + const next = { ...body, bootstrap_operations }; + return parseComposedPhaseJournal({ ...next, + journal_digest: digestComposedJson(journal.version, next) }); +}; + +export const currentBootstrapOperation = ( + journal: ComposedPhaseJournal, + kind: BootstrapOperationKind, +): ComposedBootstrapOperation | undefined => journal.bootstrap_operations?.find( + (operation) => operation.kind === kind, +); + +export const journalBootstrapOperationIntent = ( + journal: ComposedPhaseJournal, + kind: BootstrapOperationKind, + request: Readonly>, +): ComposedPhaseJournal => { + const operations = journal.bootstrap_operations ?? []; + const sequence = operations.length; + const expectedKind = COMPOSED_BOOTSTRAP_OPERATION_KINDS[sequence]; + if (journal.bootstrap === undefined || expectedKind !== kind + || currentBootstrapOperation(journal, kind) !== undefined + || operations.some(({ state }) => state !== "completed") + || (kind === "prepare_composed_run") !== (journal.execution !== undefined)) { + throw new TypeError("composed bootstrap operation intent is invalid"); + } + const request_digest = requestDigest(kind, request); + return replace(journal, [...operations, { + kind, + operation_id: digestComposedJson("simfile.composed-bootstrap-operation.v1", { + kind, request_digest, sequence, + }), + recorded_at: new Date().toISOString(), + request, + request_digest, + sequence, + state: "intent_durable", + }]); +}; + +export const journalBootstrapOperationObservation = ( + journal: ComposedPhaseJournal, + operationId: string, + state: Exclude, + receipt?: Readonly>, +): ComposedPhaseJournal => { + const operations = [...(journal.bootstrap_operations ?? [])]; + const index = operations.findIndex((operation) => operation.operation_id === operationId); + const current = operations[index]; + if (current === undefined || current.state === "completed" + || ((state === "completed") !== (receipt !== undefined))) { + throw new TypeError("composed bootstrap operation observation is invalid"); + } + operations[index] = { + ...current, + state, + ...(receipt === undefined ? {} : { receipt }), + }; + return replace(journal, operations); +}; diff --git a/src/compose/commandReceipt.test.ts b/src/compose/commandReceipt.test.ts index c8b6e9c..e2e24c6 100644 --- a/src/compose/commandReceipt.test.ts +++ b/src/compose/commandReceipt.test.ts @@ -44,7 +44,7 @@ describe("truthful composed command receipt", () => { assert.deepEqual(parseComposedCommandReceipt(receipt), receipt); assert.equal(receipt.world_claim.identity, WORLD_DECISION_CLAIM_CAPABILITY); assert.equal(receipt.world_claim.attested, true); - assert.equal(receipt.moltnet.capabilities[0], "pi-bridge"); + assert.equal(receipt.moltnet?.capabilities[0], "pi-bridge"); assert.equal(receipt.cleanup.remaining_owned_resources.length, 0); assert.equal(composedCommandExitCode(receipt), 0); }); diff --git a/src/compose/commandReceipt.ts b/src/compose/commandReceipt.ts index a4218d1..b654741 100644 --- a/src/compose/commandReceipt.ts +++ b/src/compose/commandReceipt.ts @@ -35,7 +35,7 @@ export const composedCommandReceiptSchema = z.object({ live_agent_evidence: z.object({ state: z.enum(["passed", "failed"]), zero_action_principals: z.array(z.string()) }).strict(), manifest_digest: digest, - moltnet, + moltnet: moltnet.nullable(), receipt_digest: digest, run_id: z.string().min(1), run_path: absolute, @@ -76,7 +76,8 @@ export const createComposedCommandReceipt = (input: Readonly<{ }>): ComposedCommandReceipt => { const journal = parseComposedPhaseJournal(input.journal); const lifecycle = parseComposedTerminalReceipt(input.lifecycle_receipt); - if (journal.current_phase !== "completed" || lifecycle.run_id !== journal.request.run_id + if (journal.request.mode !== "live" || journal.current_phase !== "completed" + || lifecycle.run_id !== journal.request.run_id || lifecycle.seal.state !== "sealed" || lifecycle.cleanup.state !== "cleaned" || lifecycle.verdict.state !== "valid") { throw new TypeError("composed command completion proof is invalid"); @@ -96,7 +97,7 @@ export const createComposedCommandReceipt = (input: Readonly<{ live_agent_evidence: { state: input.live_evidence.state, zero_action_principals: input.live_evidence.zero_action_principals }, manifest_digest: input.manifest_digest, - moltnet: moltnet.parse(organization.moltnet_release), + moltnet: moltnet.nullable().parse(organization.moltnet_release), run_id: lifecycle.run_id, run_path: path.resolve(input.run_path), simulation_verdict: "valid" as const, @@ -121,4 +122,3 @@ export const writeComposedProgress = (message: string): void => { export const writeComposedFinalReceipt = (receipt: ComposedCommandReceipt): void => { process.stdout.write(serializeComposedCommandReceipt(receipt)); }; - diff --git a/src/compose/composed-autonomy.test.ts b/src/compose/composed-autonomy.test.ts index 3e3af36..d2932a6 100644 --- a/src/compose/composed-autonomy.test.ts +++ b/src/compose/composed-autonomy.test.ts @@ -15,9 +15,11 @@ describe("composed supervision autonomy ratchet", () => { }); it("the production port waits only for the world-owned terminal artifact", async () => { - const source = await readFile(new URL("../spawnfile/productionPorts.ts", import.meta.url), "utf8"); - const start = source.indexOf("supervision: {"); - const end = source.indexOf("world_finalization:", start); + const source = await readFile( + new URL("../spawnfile/productionFinalizationPorts.ts", import.meta.url), "utf8", + ); + const start = source.indexOf("export const createProductionSupervisionPort"); + const end = source.indexOf("export const createProductionWorldFinalizationPort", start); assert.notEqual(start, -1); assert.notEqual(end, -1); const supervision = source.slice(start, end); diff --git a/src/compose/execution.ts b/src/compose/execution.ts index 3218cbc..ef90cc6 100644 --- a/src/compose/execution.ts +++ b/src/compose/execution.ts @@ -44,7 +44,7 @@ export const composedExecutionSchema = z.object({ asset_sha256: digest, release_version: z.string().min(1).max(128), source_revision: z.string().regex(/^[a-f0-9]{40}$/u), - }).strict(), + }).strict().optional(), selected_target_receipt_digest: digest, unit_id: identifier, world_binding_digest: digest, @@ -76,11 +76,28 @@ export const composedExecutionSchema = z.object({ process_environment: processEnvironment.optional(), spawnfile_bin: absolutePath, spawnfile_cwd: absolutePath, - target_config_producer: z.object({ - args: z.array(z.string().min(1).max(4_096)).min(1).max(32), - command: z.string().min(1).max(4_096), - transport: z.literal("stdout_to_spawnfile_stdin"), - }).strict(), + spawnfile_capability_contract_digest: digest.optional(), + spawnfile_executable_sha256: digest, + spawnfile_package_version: z.literal("0.1.17").optional(), + target_resolution: z.object({ + base_image: z.object({ + config_digest: digest, + reference: z.string().min(1).max(512), + }).strict(), + context: identifier, + endpoint_transport: z.enum(["fd", "npipe", "unix"]), + platform: z.object({ + architecture: z.enum(["amd64", "arm64"]), + os: z.literal("linux"), + }).strict(), + prepared_evidence_helper: z.object({ + digest, + handle, + version: z.literal("spawnfile.target-evidence-export-helper.prepared.v1"), + }).strict(), + target_config_digest: digest, + version: z.literal("spawnfile.target-config-resolution.v1"), + }).strict().optional(), terminal_artifact: z.object({ id: identifier, max_bytes: z.number().int().min(1).max(131_072), @@ -105,9 +122,6 @@ export type ComposedExecution = z.infer; export const parseComposedExecution = (raw: unknown): ComposedExecution => { assertSecretFreeComposedJson(raw); const value = composedExecutionSchema.parse(raw); - if (value.provider.target_config_producer.args.length !== 1) { - throw new TypeError("composed target config producer argv is invalid"); - } return Object.freeze(value); }; diff --git a/src/compose/index.ts b/src/compose/index.ts index aa6e362..66cfed8 100644 --- a/src/compose/index.ts +++ b/src/compose/index.ts @@ -1,4 +1,7 @@ export * from "./activation.js"; +export * from "./bootstrapAuthority.js"; +export * from "./bootstrapOperationContract.js"; +export * from "./bootstrapOperationJournal.js"; export * from "./cleanup.js"; export * from "./commandReceipt.js"; export * from "./contracts.js"; @@ -18,9 +21,11 @@ export * from "./replay.js"; export * from "./request.js"; export * from "./run.js"; export * from "./runRecord.js"; +export * from "./smokeCommandReceipt.js"; export * from "./startup-world.js"; export * from "./startup-organization.js"; export * from "./supervision.js"; +export * from "./terminalOutcome.js"; export * from "./types.js"; export * from "./viewer.js"; export * from "./viewerBinding.js"; diff --git a/src/compose/journal.ts b/src/compose/journal.ts index 38d1394..7dd3ec3 100644 --- a/src/compose/journal.ts +++ b/src/compose/journal.ts @@ -1,297 +1,21 @@ -import { randomBytes, randomUUID } from "node:crypto"; -import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises"; -import path from "node:path"; - -import { z } from "zod"; - -import { - assertSecretFreeComposedJson, - canonicalComposedJson, - digestComposedJson, -} from "./json.js"; -import { - composedRunRequestSchema, - createComposedRunRequestDigest, - parseComposedRunRequest, - type ComposedRunRequest, -} from "./request.js"; -import { - COMPOSED_EXECUTION_VERSION, - composedExecutionSchema, - parseComposedExecution, - type ComposedExecution, -} from "./execution.js"; -import { - COMPOSED_RUN_PHASES, - composedRunPhaseIndex, - nextComposedRunPhase, - type ComposedRunPhase, -} from "./types.js"; - -export const COMPOSED_PHASE_JOURNAL_VERSION = "simfile.composed-phase-journal.v1" as const; -export const COMPOSED_JOURNAL_AUTHORITY_VERSION = "simfile.composed-journal-authority.v1" as const; - -const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); -const timestamp = z.string().datetime({ offset: true }); -const payload = z.record(z.string(), z.unknown()); -const entry = z.object({ - payload, - payload_digest: digest, - phase: z.enum(COMPOSED_RUN_PHASES), - recorded_at: timestamp, - sequence: z.number().int().min(0).max(COMPOSED_RUN_PHASES.length - 1), -}).strict(); - -export const composedPhaseJournalSchema = z.object({ - authority_digest: digest, - current_phase: z.enum(COMPOSED_RUN_PHASES), - entries: z.array(entry).min(1).max(COMPOSED_RUN_PHASES.length), - execution: composedExecutionSchema.optional(), - genesis_nonce: z.string().regex(/^[a-f0-9]{64}$/u), - interruption: z.object({ - next_phase: z.enum(COMPOSED_RUN_PHASES), - recovery_command: z.string().min(1).max(8_192), - signal: z.enum(["SIGINT", "SIGTERM", "restart", "failure"]), - }).strict().nullable(), - journal_digest: digest, - request: composedRunRequestSchema, - request_digest: digest, - state: z.enum(["active", "recoverable", "complete"]), - version: z.literal(COMPOSED_PHASE_JOURNAL_VERSION), -}).strict(); - -export type ComposedPhaseJournal = z.infer; - -const payloadDigest = (phase: ComposedRunPhase, value: unknown): `sha256:${string}` => - digestComposedJson(`simfile.composed-phase.${phase}.v1`, value); - -const parseTimestamp = (value: string): number => { - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) throw new TypeError("composed journal timestamp is invalid"); - return parsed; -}; - -const authorityDigest = (input: Readonly<{ - execution?: ComposedExecution; - genesis_nonce: string; - recorded_at: string; - request_digest: string; -}>): `sha256:${string}` => digestComposedJson(COMPOSED_JOURNAL_AUTHORITY_VERSION, { - execution_digest: input.execution === undefined ? null - : digestComposedJson(COMPOSED_EXECUTION_VERSION, input.execution), - genesis_nonce: input.genesis_nonce, - recorded_at: input.recorded_at, - request_digest: input.request_digest, -}); - -export const parseComposedPhaseJournal = (raw: unknown): ComposedPhaseJournal => { - assertSecretFreeComposedJson(raw); - const journal = composedPhaseJournalSchema.parse(raw); - const execution = journal.execution === undefined - ? undefined - : parseComposedExecution(journal.execution); - const expectedAuthority = authorityDigest({ - ...(execution === undefined ? {} : { execution }), - genesis_nonce: journal.genesis_nonce, - recorded_at: journal.entries[0]!.recorded_at, - request_digest: journal.request_digest, - }); - if (journal.request_digest !== createComposedRunRequestDigest(journal.request) - || journal.authority_digest !== expectedAuthority - || journal.entries.length !== composedRunPhaseIndex(journal.current_phase) + 1 - || (execution !== undefined - && execution.provider.target_config_producer.args[0] !== journal.request.target.selector) - || (execution !== undefined - && execution.configuration.readiness_expectation.run_id !== journal.request.run_id) - || (execution !== undefined - && execution.configuration.readiness_expectation.bundle_digest - !== journal.request.world.bundle_digest) - || (execution !== undefined - && execution.configuration.organization_expectation.world_binding_digest - !== journal.request.organization.world_bindings_digest)) { - throw new TypeError("composed journal correlation is invalid"); - } - let previousTime = -Infinity; - for (const [index, phaseEntry] of journal.entries.entries()) { - assertSecretFreeComposedJson(phaseEntry.payload); - if (phaseEntry.phase !== COMPOSED_RUN_PHASES[index] - || phaseEntry.sequence !== index - || phaseEntry.payload.run_id !== journal.request.run_id - || phaseEntry.payload_digest !== payloadDigest(phaseEntry.phase, phaseEntry.payload)) { - throw new TypeError("composed journal transition is invalid"); - } - const currentTime = parseTimestamp(phaseEntry.recorded_at); - if (currentTime < previousTime) throw new TypeError("composed journal time regressed"); - previousTime = currentTime; - } - if ((journal.state === "complete") !== (journal.current_phase === "completed") - || (journal.state === "recoverable") !== (journal.interruption !== null)) { - throw new TypeError("composed journal state is contradictory"); - } - if (journal.interruption !== null - && journal.interruption.next_phase !== nextComposedRunPhase(journal.current_phase)) { - throw new TypeError("composed journal recovery phase is invalid"); - } - const { journal_digest: _journalDigest, ...body } = journal; - if (journal.journal_digest !== digestComposedJson(COMPOSED_PHASE_JOURNAL_VERSION, body)) { - throw new TypeError("composed journal digest is invalid"); - } - return Object.freeze(journal); -}; - -const seal = ( - body: Omit, -): ComposedPhaseJournal => parseComposedPhaseJournal({ - ...body, - journal_digest: digestComposedJson(COMPOSED_PHASE_JOURNAL_VERSION, body), -}); - -const phaseEntry = ( - phase: ComposedRunPhase, - value: Record, - recordedAt: string, -) => ({ - payload: value, - payload_digest: payloadDigest(phase, value), - phase, - recorded_at: timestamp.parse(recordedAt), - sequence: composedRunPhaseIndex(phase), -}); - -export const createComposedPhaseJournal = ( - rawRequest: unknown, - recordedAt: string, - rawExecution?: unknown, -): ComposedPhaseJournal => { - const request = parseComposedRunRequest(rawRequest); - const execution = rawExecution === undefined ? undefined : parseComposedExecution(rawExecution); - const initial = phaseEntry("requested", { - request_digest: createComposedRunRequestDigest(request), - run_id: request.run_id, - }, recordedAt); - const genesisNonce = randomBytes(32).toString("hex"); - const authority = authorityDigest({ - ...(execution === undefined ? {} : { execution }), - genesis_nonce: genesisNonce, - recorded_at: initial.recorded_at, - request_digest: createComposedRunRequestDigest(request), - }); - return seal({ - authority_digest: authority, - current_phase: "requested", - entries: [initial], - ...(execution === undefined ? {} : { execution }), - genesis_nonce: genesisNonce, - interruption: null, - request, - request_digest: createComposedRunRequestDigest(request), - state: "active", - version: COMPOSED_PHASE_JOURNAL_VERSION, - }); -}; - -export const appendComposedPhase = ( - rawJournal: unknown, - phase: ComposedRunPhase, - rawPayload: Record, - recordedAt: string, -): ComposedPhaseJournal => { - const journal = parseComposedPhaseJournal(rawJournal); - assertSecretFreeComposedJson(rawPayload); - if (rawPayload.run_id !== journal.request.run_id) { - throw new TypeError("composed phase run correlation is invalid"); - } - const requestedIndex = composedRunPhaseIndex(phase); - const currentIndex = composedRunPhaseIndex(journal.current_phase); - if (requestedIndex <= currentIndex) { - const existing = journal.entries[requestedIndex]; - if (!existing || existing.payload_digest !== payloadDigest(phase, rawPayload)) { - throw new TypeError("composed phase replay is contradictory"); - } - return journal; - } - if (requestedIndex !== currentIndex + 1 || journal.state === "complete") { - throw new TypeError("composed phase transition is not monotonic"); - } - const entries = [...journal.entries, phaseEntry(phase, rawPayload, recordedAt)]; - return seal({ - authority_digest: journal.authority_digest, - current_phase: phase, - entries, - ...(journal.execution === undefined ? {} : { execution: journal.execution }), - genesis_nonce: journal.genesis_nonce, - interruption: null, - request: journal.request, - request_digest: journal.request_digest, - state: phase === "completed" ? "complete" : "active", - version: COMPOSED_PHASE_JOURNAL_VERSION, - }); -}; - -export const markComposedJournalRecoverable = ( - rawJournal: unknown, - input: Readonly<{ - recovery_command: string; - signal: "SIGINT" | "SIGTERM" | "restart" | "failure"; - }>, -): ComposedPhaseJournal => { - const journal = parseComposedPhaseJournal(rawJournal); - const nextPhase = nextComposedRunPhase(journal.current_phase); - if (nextPhase === null || journal.state === "complete") { - throw new TypeError("completed journal cannot require recovery"); - } - return seal({ - authority_digest: journal.authority_digest, - current_phase: journal.current_phase, - entries: journal.entries, - ...(journal.execution === undefined ? {} : { execution: journal.execution }), - genesis_nonce: journal.genesis_nonce, - interruption: { - next_phase: nextPhase, - recovery_command: input.recovery_command, - signal: input.signal, - }, - request: journal.request, - request_digest: journal.request_digest, - state: "recoverable", - version: COMPOSED_PHASE_JOURNAL_VERSION, - }); -}; - -const exactJournalPath = (value: string): string => { - if (!path.isAbsolute(value) || path.normalize(value) !== value - || value === path.parse(value).root || Buffer.byteLength(value, "utf8") > 4_096) { - throw new TypeError("composed journal path is invalid"); - } - return value; -}; - -export const writeComposedPhaseJournal = async ( - journalPath: string, - rawJournal: unknown, -): Promise => { - const target = exactJournalPath(journalPath); - const journal = parseComposedPhaseJournal(rawJournal); - const directory = path.dirname(target); - await mkdir(directory, { recursive: true, mode: 0o700 }); - await chmod(directory, 0o700); - const temporary = `${target}.${process.pid}.${randomUUID()}.pending`; - const handle = await open(temporary, "wx", 0o600); - try { - await handle.writeFile(`${canonicalComposedJson(journal)}\n`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - try { - await rename(temporary, target); - } finally { - await unlink(temporary).catch(() => undefined); - } -}; - -export const readComposedPhaseJournal = async ( - journalPath: string, -): Promise => parseComposedPhaseJournal( - JSON.parse(await readFile(exactJournalPath(journalPath), "utf8")) as unknown, -); +export { + COMPOSED_JOURNAL_AUTHORITY_VERSION, + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + COMPOSED_PHASE_JOURNAL_VERSION, + composedPhaseJournalSchema, + type ComposedPhaseJournal, +} from "./journalSchema.js"; +export { parseComposedPhaseJournal } from "./journalValidation.js"; +export { + createBootstrapComposedPhaseJournal, + createComposedPhaseJournal, +} from "./journalGenesis.js"; +export { + appendComposedPhase, + bindComposedJournalExecution, + markComposedJournalRecoverable, +} from "./journalTransitions.js"; +export { + readComposedPhaseJournal, + writeComposedPhaseJournal, +} from "./journalStore.js"; diff --git a/src/compose/journalGenesis.ts b/src/compose/journalGenesis.ts new file mode 100644 index 0000000..ae4acb9 --- /dev/null +++ b/src/compose/journalGenesis.ts @@ -0,0 +1,77 @@ +import { randomBytes } from "node:crypto"; + +import { parseComposedBootstrapCapsule } from "./bootstrapAuthority.js"; +import { parseComposedExecution } from "./execution.js"; +import { + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + COMPOSED_PHASE_JOURNAL_VERSION, + journalTimestampSchema, + type ComposedPhaseJournal, +} from "./journalSchema.js"; +import { + composedJournalAuthorityDigest, + composedPhasePayloadDigest, + sealComposedPhaseJournal, +} from "./journalValidation.js"; +import { + createComposedRunRequestDigest, + parseComposedRunRequest, +} from "./request.js"; +import { composedRunPhaseIndex, type ComposedRunPhase } from "./types.js"; + +const phaseEntry = (phase: ComposedRunPhase, value: Record, recordedAt: string) => ({ + payload: value, + payload_digest: composedPhasePayloadDigest(phase, value), + phase, + recorded_at: journalTimestampSchema.parse(recordedAt), + sequence: composedRunPhaseIndex(phase), +}); + +export const createComposedPhaseJournal = ( + rawRequest: unknown, + recordedAt: string, + rawExecution?: unknown, +): ComposedPhaseJournal => { + const request = parseComposedRunRequest(rawRequest); + const execution = rawExecution === undefined ? undefined : parseComposedExecution(rawExecution); + const requestDigest = createComposedRunRequestDigest(request); + const initial = phaseEntry("requested", { request_digest: requestDigest, + run_id: request.run_id }, recordedAt); + const genesisNonce = randomBytes(32).toString("hex"); + return sealComposedPhaseJournal({ + authority_digest: composedJournalAuthorityDigest({ + ...(execution === undefined ? {} : { execution }), genesis_nonce: genesisNonce, + recorded_at: initial.recorded_at, request_digest: requestDigest, + }), + current_phase: "requested", entries: [initial], + ...(execution === undefined ? {} : { execution }), genesis_nonce: genesisNonce, + interruption: null, operations: [], request, request_digest: requestDigest, + state: "active", version: COMPOSED_PHASE_JOURNAL_VERSION, + }); +}; + +/** Creates and seals the pre-target authority before any provider/auth mutation. */ +export const createBootstrapComposedPhaseJournal = ( + rawRequest: unknown, + rawBootstrap: unknown, + recordedAt: string, +): ComposedPhaseJournal => { + const request = parseComposedRunRequest(rawRequest); + const bootstrap = parseComposedBootstrapCapsule(rawBootstrap); + if (bootstrap.run_id !== request.run_id) { + throw new TypeError("composed bootstrap run correlation is invalid"); + } + const requestDigest = createComposedRunRequestDigest(request); + const initial = phaseEntry("requested", { request_digest: requestDigest, + run_id: request.run_id }, recordedAt); + const genesisNonce = randomBytes(32).toString("hex"); + return sealComposedPhaseJournal({ + authority_digest: composedJournalAuthorityDigest({ bootstrap, + genesis_nonce: genesisNonce, recorded_at: initial.recorded_at, + request_digest: requestDigest }), + bootstrap, bootstrap_operations: [], current_phase: "requested", entries: [initial], + genesis_nonce: genesisNonce, interruption: null, operations: [], request, + request_digest: requestDigest, state: "active", + version: COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + }); +}; diff --git a/src/compose/journalSchema.ts b/src/compose/journalSchema.ts new file mode 100644 index 0000000..def5b03 --- /dev/null +++ b/src/compose/journalSchema.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { + composedBootstrapBindingSchema, + composedBootstrapCapsuleSchema, +} from "./bootstrapAuthority.js"; +import { composedBootstrapOperationSchema } from "./bootstrapOperationContract.js"; +import { composedExecutionSchema } from "./execution.js"; +import { composedRunRequestSchema } from "./request.js"; +import { COMPOSED_RUN_PHASES } from "./types.js"; + +export const COMPOSED_PHASE_JOURNAL_VERSION = + "simfile.composed-phase-journal.v1" as const; +export const COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION = + "simfile.composed-phase-journal.v2" as const; +export const COMPOSED_JOURNAL_AUTHORITY_VERSION = + "simfile.composed-journal-authority.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +export const journalTimestampSchema = z.string().datetime({ offset: true }); +const payload = z.record(z.string(), z.unknown()); +const entry = z.object({ + payload, + payload_digest: digest, + phase: z.enum(COMPOSED_RUN_PHASES), + recorded_at: journalTimestampSchema, + sequence: z.number().int().min(0).max(COMPOSED_RUN_PHASES.length - 1), +}).strict(); +const operation = z.object({ + command: z.string().regex(/^[a-z][a-z_]{1,63}$/u), + operation_id: digest, + recorded_at: journalTimestampSchema, + request: payload, + request_digest: digest, + sequence: z.number().int().min(0).max(1_023), + state: z.enum(["intent_durable", "completed", "lookup_required", "not_applied", "pending"]), + target_receipt: payload.optional(), +}).strict(); + +export const composedPhaseJournalSchema = z.object({ + authority_digest: digest, + bootstrap: composedBootstrapCapsuleSchema.optional(), + bootstrap_binding: composedBootstrapBindingSchema.optional(), + bootstrap_operations: z.array(composedBootstrapOperationSchema).max(16).optional(), + current_phase: z.enum(COMPOSED_RUN_PHASES), + entries: z.array(entry).min(1).max(COMPOSED_RUN_PHASES.length), + execution: composedExecutionSchema.optional(), + genesis_nonce: z.string().regex(/^[a-f0-9]{64}$/u), + interruption: z.object({ + next_phase: z.enum(COMPOSED_RUN_PHASES), + recovery_command: z.string().min(1).max(8_192), + signal: z.enum(["SIGINT", "SIGTERM", "restart", "failure"]), + }).strict().nullable(), + journal_digest: digest, + operations: z.array(operation).max(1_024).optional(), + request: composedRunRequestSchema, + request_digest: digest, + state: z.enum(["active", "recoverable", "complete"]), + version: z.enum([ + COMPOSED_PHASE_JOURNAL_VERSION, + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + ]), +}).strict(); + +export type ComposedPhaseJournal = z.infer; diff --git a/src/compose/journalStore.ts b/src/compose/journalStore.ts new file mode 100644 index 0000000..9937ae5 --- /dev/null +++ b/src/compose/journalStore.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { canonicalComposedJson } from "./json.js"; +import type { ComposedPhaseJournal } from "./journalSchema.js"; +import { parseComposedPhaseJournal } from "./journalValidation.js"; + +const exactJournalPath = (value: string): string => { + if (!path.isAbsolute(value) || path.normalize(value) !== value + || value === path.parse(value).root || Buffer.byteLength(value, "utf8") > 4_096) { + throw new TypeError("composed journal path is invalid"); + } + return value; +}; + +export const writeComposedPhaseJournal = async ( + journalPath: string, + rawJournal: unknown, +): Promise => { + const target = exactJournalPath(journalPath); + const journal = parseComposedPhaseJournal(rawJournal); + const directory = path.dirname(target); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporary = `${target}.${process.pid}.${randomUUID()}.pending`; + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${canonicalComposedJson(journal)}\n`, "utf8"); + await handle.sync(); + } finally { await handle.close(); } + try { await rename(temporary, target); } + finally { await unlink(temporary).catch(() => undefined); } +}; + +export const readComposedPhaseJournal = async ( + journalPath: string, +): Promise => parseComposedPhaseJournal( + JSON.parse(await readFile(exactJournalPath(journalPath), "utf8")) as unknown, +); diff --git a/src/compose/journalTransitions.ts b/src/compose/journalTransitions.ts new file mode 100644 index 0000000..6e94d2a --- /dev/null +++ b/src/compose/journalTransitions.ts @@ -0,0 +1,94 @@ +import { + composedBootstrapDigest, + parseComposedBootstrapBinding, +} from "./bootstrapAuthority.js"; +import { COMPOSED_EXECUTION_VERSION, parseComposedExecution } from "./execution.js"; +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; +import { + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + type ComposedPhaseJournal, +} from "./journalSchema.js"; +import { + composedJournalAuthorityDigest, + composedPhasePayloadDigest, + parseComposedPhaseJournal, + sealComposedPhaseJournal, +} from "./journalValidation.js"; +import { composedRunPhaseIndex, nextComposedRunPhase, type ComposedRunPhase } from "./types.js"; + +export const appendComposedPhase = (rawJournal: unknown, phase: ComposedRunPhase, + rawPayload: Record, recordedAt: string): ComposedPhaseJournal => { + const journal = parseComposedPhaseJournal(rawJournal); + assertSecretFreeComposedJson(rawPayload); + if (rawPayload.run_id !== journal.request.run_id) { + throw new TypeError("composed phase run correlation is invalid"); + } + const requestedIndex = composedRunPhaseIndex(phase); + const currentIndex = composedRunPhaseIndex(journal.current_phase); + if (requestedIndex <= currentIndex) { + const existing = journal.entries[requestedIndex]; + if (!existing || existing.payload_digest !== composedPhasePayloadDigest(phase, rawPayload)) { + throw new TypeError("composed phase replay is contradictory"); + } + return journal; + } + if (requestedIndex !== currentIndex + 1 || journal.state === "complete") { + throw new TypeError("composed phase transition is not monotonic"); + } + const entries = [...journal.entries, { + payload: rawPayload, payload_digest: composedPhasePayloadDigest(phase, rawPayload), phase, + recorded_at: recordedAt, sequence: requestedIndex, + }]; + const { journal_digest: _digest, ...body } = journal; + return sealComposedPhaseJournal({ ...body, current_phase: phase, entries, + interruption: null, state: phase === "completed" ? "complete" : "active" }); +}; + +/** Binds provider identity only after all bootstrap mutation receipts are verified. */ +export const bindComposedJournalExecution = (rawJournal: unknown, + rawExecution: unknown, rawBinding?: unknown): ComposedPhaseJournal => { + const journal = parseComposedPhaseJournal(rawJournal); + const execution = parseComposedExecution(rawExecution); + if (journal.execution !== undefined || journal.current_phase !== "requested" + || (journal.operations?.length ?? 0) !== 0 + || journal.bootstrap_operations?.length !== 4 + || journal.bootstrap_operations.some(({ state }) => state !== "completed") + || execution.configuration.readiness_expectation.run_id !== journal.request.run_id + || execution.configuration.readiness_expectation.bundle_digest !== journal.request.world.bundle_digest + || execution.configuration.organization_expectation.world_binding_digest + !== journal.request.organization.world_bindings_digest) { + throw new TypeError("composed execution binding is invalid"); + } + const binding = journal.version === COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION + ? parseComposedBootstrapBinding(rawBinding) : undefined; + const authority = composedJournalAuthorityDigest({ + ...(journal.bootstrap === undefined ? {} : { bootstrap: journal.bootstrap }), + execution, genesis_nonce: journal.genesis_nonce, + recorded_at: journal.entries[0]!.recorded_at, request_digest: journal.request_digest, + }); + if (binding !== undefined && (binding.bootstrap_authority_digest !== authority + || binding.execution_digest !== digestComposedJson(COMPOSED_EXECUTION_VERSION, execution) + || binding.request_digest !== journal.request_digest || binding.run_id !== journal.request.run_id + || journal.bootstrap === undefined + || binding.bootstrap_digest !== composedBootstrapDigest(journal.bootstrap))) { + throw new TypeError("composed bootstrap binding is invalid"); + } + const { journal_digest: _digest, ...body } = journal; + return sealComposedPhaseJournal({ ...body, authority_digest: authority, + ...(binding === undefined ? {} : { bootstrap_binding: binding }), execution }); +}; + +export const markComposedJournalRecoverable = (rawJournal: unknown, input: Readonly<{ + recovery_command: string; + signal: "SIGINT" | "SIGTERM" | "restart" | "failure"; +}>): ComposedPhaseJournal => { + const journal = parseComposedPhaseJournal(rawJournal); + const nextPhase = nextComposedRunPhase(journal.current_phase); + if (nextPhase === null || journal.state === "complete") { + throw new TypeError("completed journal cannot require recovery"); + } + const { journal_digest: _digest, ...body } = journal; + return sealComposedPhaseJournal({ ...body, interruption: { + next_phase: nextPhase, recovery_command: input.recovery_command, signal: input.signal, + }, state: "recoverable" }); +}; diff --git a/src/compose/journalValidation.ts b/src/compose/journalValidation.ts new file mode 100644 index 0000000..99143dc --- /dev/null +++ b/src/compose/journalValidation.ts @@ -0,0 +1,170 @@ +import { + composedBootstrapDigest, + parseComposedBootstrapBinding, + parseComposedBootstrapCapsule, + type ComposedBootstrapCapsule, +} from "./bootstrapAuthority.js"; +import { COMPOSED_BOOTSTRAP_OPERATION_KINDS } from "./bootstrapOperationContract.js"; +import { + COMPOSED_EXECUTION_VERSION, + parseComposedExecution, + type ComposedExecution, +} from "./execution.js"; +import { + assertSecretFreeComposedJson, + canonicalComposedJson, + digestComposedJson, +} from "./json.js"; +import { + COMPOSED_JOURNAL_AUTHORITY_VERSION, + COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION, + COMPOSED_PHASE_JOURNAL_VERSION, + composedPhaseJournalSchema, + type ComposedPhaseJournal, +} from "./journalSchema.js"; +import { createComposedRunRequestDigest } from "./request.js"; +import { + COMPOSED_RUN_PHASES, + composedRunPhaseIndex, + nextComposedRunPhase, + type ComposedRunPhase, +} from "./types.js"; + +export const composedPhasePayloadDigest = ( + phase: ComposedRunPhase, + value: unknown, +): `sha256:${string}` => digestComposedJson(`simfile.composed-phase.${phase}.v1`, value); + +export const composedJournalAuthorityDigest = (input: Readonly<{ + bootstrap?: ComposedBootstrapCapsule; + execution?: ComposedExecution; + genesis_nonce: string; + recorded_at: string; + request_digest: string; +}>): `sha256:${string}` => digestComposedJson(COMPOSED_JOURNAL_AUTHORITY_VERSION, { + bootstrap_digest: input.bootstrap === undefined ? null : composedBootstrapDigest(input.bootstrap), + execution_digest: input.bootstrap !== undefined || input.execution === undefined ? null + : digestComposedJson(COMPOSED_EXECUTION_VERSION, input.execution), + genesis_nonce: input.genesis_nonce, + recorded_at: input.recorded_at, + request_digest: input.request_digest, +}); + +const assertBinding = (journal: ComposedPhaseJournal): void => { + if (journal.bootstrap_binding === undefined) { + if (journal.version === COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION + && journal.execution !== undefined) { + throw new TypeError("composed bootstrap execution is unbound"); + } + return; + } + const binding = parseComposedBootstrapBinding(journal.bootstrap_binding); + const bootstrap = journal.bootstrap; + const execution = journal.execution; + const resolution = execution?.provider.target_resolution; + const selected = execution?.configuration.topology_expectation.selected_target; + if (bootstrap === undefined || execution === undefined + || binding.bootstrap_authority_digest !== journal.authority_digest + || binding.bootstrap_digest !== composedBootstrapDigest(bootstrap) + || binding.execution_digest !== digestComposedJson(COMPOSED_EXECUTION_VERSION, execution) + || binding.request_digest !== journal.request_digest || binding.run_id !== journal.request.run_id + || resolution === undefined || selected === undefined + || binding.target.context !== resolution.context + || binding.target.target_config_digest !== resolution.target_config_digest + || canonicalComposedJson(binding.target.prepared_evidence_helper) + !== canonicalComposedJson(resolution.prepared_evidence_helper) + || canonicalComposedJson(binding.target.selected_target) !== canonicalComposedJson(selected) + || binding.target.selected_target_receipt_digest + !== execution.configuration.organization_expectation.selected_target_receipt_digest) { + throw new TypeError("composed bootstrap binding correlation is invalid"); + } +}; + +const assertEntries = (journal: ComposedPhaseJournal): void => { + let previousTime = -Infinity; + for (const [index, item] of journal.entries.entries()) { + assertSecretFreeComposedJson(item.payload); + const time = Date.parse(item.recorded_at); + if (item.phase !== COMPOSED_RUN_PHASES[index] || item.sequence !== index + || item.payload.run_id !== journal.request.run_id + || item.payload_digest !== composedPhasePayloadDigest(item.phase, item.payload) + || !Number.isFinite(time) || time < previousTime) { + throw new TypeError("composed journal transition is invalid"); + } + previousTime = time; + } +}; + +const assertOperations = (journal: ComposedPhaseJournal): void => { + for (const [index, item] of (journal.operations ?? []).entries()) { + assertSecretFreeComposedJson(item.request); + if (item.sequence !== index || item.request.run_id !== journal.request.run_id + || item.request_digest !== digestComposedJson("spawnfile.target-resource.request.v1", item.request) + || item.operation_id !== digestComposedJson("simfile.composed-operation.v1", { + command: item.command, request_digest: item.request_digest, sequence: item.sequence, + }) || ((item.state === "completed") !== (item.target_receipt !== undefined))) { + throw new TypeError("composed operation journal correlation is invalid"); + } + } + const bootstrapOperations = journal.bootstrap_operations ?? []; + for (const [index, item] of bootstrapOperations.entries()) { + assertSecretFreeComposedJson(item.request); + if (item.sequence !== index || item.kind !== COMPOSED_BOOTSTRAP_OPERATION_KINDS[index] + || (index < bootstrapOperations.length - 1 && item.state !== "completed") + || item.request_digest !== digestComposedJson( + "simfile.composed-bootstrap-operation-request.v1", { kind: item.kind, request: item.request }, + ) || item.operation_id !== digestComposedJson("simfile.composed-bootstrap-operation.v1", { + kind: item.kind, request_digest: item.request_digest, sequence: item.sequence, + }) || ((item.state === "completed") !== (item.receipt !== undefined))) { + throw new TypeError("composed bootstrap operation correlation is invalid"); + } + } +}; + +export const parseComposedPhaseJournal = (raw: unknown): ComposedPhaseJournal => { + assertSecretFreeComposedJson(raw); + const journal = composedPhaseJournalSchema.parse(raw); + const bootstrap = journal.bootstrap === undefined + ? undefined : parseComposedBootstrapCapsule(journal.bootstrap); + const execution = journal.execution === undefined ? undefined : parseComposedExecution(journal.execution); + const expectedAuthority = composedJournalAuthorityDigest({ + ...(bootstrap === undefined ? {} : { bootstrap }), + ...(execution === undefined ? {} : { execution }), genesis_nonce: journal.genesis_nonce, + recorded_at: journal.entries[0]!.recorded_at, request_digest: journal.request_digest, + }); + if (journal.request_digest !== createComposedRunRequestDigest(journal.request) + || journal.authority_digest !== expectedAuthority + || journal.entries.length !== composedRunPhaseIndex(journal.current_phase) + 1 + || execution?.configuration.readiness_expectation.run_id !== undefined + && execution.configuration.readiness_expectation.run_id !== journal.request.run_id + || execution?.configuration.readiness_expectation.bundle_digest !== undefined + && execution.configuration.readiness_expectation.bundle_digest !== journal.request.world.bundle_digest + || execution?.configuration.organization_expectation.world_binding_digest !== undefined + && execution.configuration.organization_expectation.world_binding_digest + !== journal.request.organization.world_bindings_digest + || journal.version === COMPOSED_PHASE_JOURNAL_VERSION + && (bootstrap !== undefined || journal.bootstrap_binding !== undefined + || journal.bootstrap_operations !== undefined) + || journal.version === COMPOSED_PHASE_JOURNAL_BOOTSTRAP_VERSION + && (bootstrap === undefined || bootstrap.run_id !== journal.request.run_id)) { + throw new TypeError("composed journal correlation is invalid"); + } + assertBinding(journal); assertEntries(journal); assertOperations(journal); + if ((journal.state === "complete") !== (journal.current_phase === "completed") + || (journal.state === "recoverable") !== (journal.interruption !== null) + || journal.interruption !== null + && journal.interruption.next_phase !== nextComposedRunPhase(journal.current_phase)) { + throw new TypeError("composed journal state is contradictory"); + } + const { journal_digest: _digest, ...body } = journal; + if (journal.journal_digest !== digestComposedJson(journal.version, body)) { + throw new TypeError("composed journal digest is invalid"); + } + return Object.freeze(journal); +}; + +export const sealComposedPhaseJournal = ( + body: Omit, +): ComposedPhaseJournal => parseComposedPhaseJournal({ + ...body, journal_digest: digestComposedJson(body.version, body), +}); diff --git a/src/compose/json.ts b/src/compose/json.ts index 65c50dc..641bea1 100644 --- a/src/compose/json.ts +++ b/src/compose/json.ts @@ -4,7 +4,8 @@ import { types } from "node:util"; const MAX_DEPTH = 32; const MAX_NODES = 4_096; const MAX_KEYS = 256; -const MAX_STRING_BYTES = 262_144; +// Composed bootstrap contains a schema-bounded base64 world archive (4 MiB raw). +const MAX_STRING_BYTES = 6_291_456; const forbiddenKey = /^(?:authorization|bearer|credential|password|private_config|secret|target_config|token)$/iu; const secretValue = /(?:\bBearer\s+\S+|\b(?:password|token)\s*=|-----BEGIN [A-Z ]+PRIVATE KEY-----|\bsk-[A-Za-z0-9_-]{16,})/u; diff --git a/src/compose/lifecycle.test-helper.ts b/src/compose/lifecycle.test-helper.ts index a1cf460..2c04462 100644 --- a/src/compose/lifecycle.test-helper.ts +++ b/src/compose/lifecycle.test-helper.ts @@ -45,7 +45,7 @@ export const lifecycleRequest = ( required_world_capabilities: [], run_id: "run-lifecycle", source_digest: lifecycleDigest("e"), - target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + target: { auth_profile: "test-auth-profile", selector: "local-test-target" }, version: "simfile.composed-run-request.v1", world: { artifact_manifest_digest: lifecycleDigest("f"), diff --git a/src/compose/operationJournal.ts b/src/compose/operationJournal.ts new file mode 100644 index 0000000..69fa552 --- /dev/null +++ b/src/compose/operationJournal.ts @@ -0,0 +1,40 @@ +import { appendComposedPhase, parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { digestComposedJson } from "./json.js"; + +export type ComposedOperationState = "intent_durable" | "completed" | "lookup_required" | "not_applied" | "pending"; + +const replaceOperations = (journal: ComposedPhaseJournal, operations: readonly Record[]): ComposedPhaseJournal => { + const { journal_digest: _digest, ...body } = journal; + return parseComposedPhaseJournal({ ...body, operations, + journal_digest: digestComposedJson(journal.version, { ...body, operations }) }); +}; +const record = (journal: ComposedPhaseJournal, command: string, request: Readonly>, + state: ComposedOperationState, receipt?: Readonly>): Record => { + const sequence = journal.operations?.length ?? 0; + const requestDigest = digestComposedJson("spawnfile.target-resource.request.v1", request); + return { command, operation_id: digestComposedJson("simfile.composed-operation.v1", { + command, request_digest: requestDigest, sequence }), recorded_at: new Date().toISOString(), request, + request_digest: requestDigest, sequence, state, ...(receipt === undefined ? {} : { target_receipt: receipt }) }; +}; + +/** Appends an fsync-ready immutable target-mutation intent before invoking Spawnfile. */ +export const journalTargetOperationIntent = (journal: ComposedPhaseJournal, command: string, + request: Readonly>): ComposedPhaseJournal => replaceOperations(journal, [ + ...(journal.operations ?? []), record(journal, command, request, "intent_durable"), +]); +export const journalTargetOperationObservation = (journal: ComposedPhaseJournal, operation_id: string, + state: Exclude, receipt?: Readonly>): ComposedPhaseJournal => { + const operations = [...(journal.operations ?? [])]; + const current = operations.find((entry) => entry.operation_id === operation_id); + if (current === undefined || current.state === "completed" + || ((state === "completed") !== (receipt !== undefined))) { + throw new TypeError("composed operation observation is invalid"); + } + operations[current.sequence as number] = { ...current, state, ...(receipt === undefined ? {} : { target_receipt: receipt }) }; + return replaceOperations(journal, operations); +}; +export const currentTargetOperation = (journal: ComposedPhaseJournal, command: string, + request: Readonly>): Readonly> | undefined => { + const request_digest = digestComposedJson("spawnfile.target-resource.request.v1", request); + return journal.operations?.find((entry) => entry.command === command && entry.request_digest === request_digest); +}; diff --git a/src/compose/phase-journal.test.ts b/src/compose/phase-journal.test.ts index 003c6de..69007c6 100644 --- a/src/compose/phase-journal.test.ts +++ b/src/compose/phase-journal.test.ts @@ -26,7 +26,7 @@ const request = { required_world_capabilities: [], run_id: "run-one", source_digest: sha("e"), - target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + target: { auth_profile: "test-auth-profile", selector: "local-test-target" }, version: "simfile.composed-run-request.v1", world: { artifact_manifest_digest: sha("f"), bundle_digest: sha("1"), @@ -83,11 +83,7 @@ const execution = { organization_path: "/tmp/organization.yaml", spawnfile_bin: "/tmp/spawnfile/dist/cli/index.js", spawnfile_cwd: "/tmp/spawnfile", - target_config_producer: { - args: [request.target.selector], - command: "/usr/local/bin/target-config-producer", - transport: "stdout_to_spawnfile_stdin", - }, + spawnfile_executable_sha256: sha("1"), terminal_artifact: { id: "terminal_receipt", max_bytes: 131_072, @@ -183,20 +179,7 @@ test("journal durably binds only nonsecret execution inputs for exact restart", ...execution, provider: { ...execution.provider, - target_config_producer: { - ...execution.provider.target_config_producer, - args: ["foreign-target"], - }, - }, - }), /correlation/u); - assert.throws(() => createComposedPhaseJournal(request, at(0), { - ...execution, - provider: { - ...execution.provider, - target_config_producer: { - ...execution.provider.target_config_producer, - command: "token=must-not-persist", - }, + spawnfile_bin: "token=must-not-persist", }, }), /secret-shaped/u); }); diff --git a/src/compose/projectBinding.ts b/src/compose/projectBinding.ts index 3d40d92..170c620 100644 --- a/src/compose/projectBinding.ts +++ b/src/compose/projectBinding.ts @@ -82,6 +82,12 @@ export interface ComposedProjectPreparation { readonly world_members: readonly z.infer[]; } +/** + * Trusted project-code contract: binding module evaluation and + * prepareComposedProject execution must not cause lifecycle or Simfile + * support-state effects. Preparation may only author and return its declared + * project artifact inputs. Simfile does not sandbox arbitrary project JavaScript. + */ export interface ComposedProjectBinding { readonly version: typeof COMPOSED_PROJECT_BINDING_VERSION; prepareComposedProject( @@ -89,7 +95,7 @@ export interface ComposedProjectBinding { ): Promise; } -const parsePreparation = ( +const validateComposedProjectPreparation = ( value: ComposedProjectPreparation, input: PrepareComposedProjectInput, ): ComposedProjectPreparation => { @@ -142,7 +148,7 @@ export const createComposedProjectBinding = ( throw new TypeError("composed project binding prepare function is invalid"); } return Object.freeze({ - prepareComposedProject: async (request: PrepareComposedProjectInput) => parsePreparation( + prepareComposedProject: async (request: PrepareComposedProjectInput) => validateComposedProjectPreparation( await input.prepareComposedProject(request), request, ), version: COMPOSED_PROJECT_BINDING_VERSION, diff --git a/src/compose/recovery.ts b/src/compose/recovery.ts index 53ebdd0..c671835 100644 --- a/src/compose/recovery.ts +++ b/src/compose/recovery.ts @@ -1,6 +1,5 @@ -import path from "node:path"; - import { + appendComposedPhase, createComposedPhaseJournal, markComposedJournalRecoverable, type ComposedPhaseJournal, @@ -146,8 +145,16 @@ export const runDurableComposedRun = async ( fault_injector: { afterPhase: boundary }, now, persist: async (journal) => { - await loaded.session.replace(latest!, journal); - latest = journal; + const current = loaded.session.current(); + const proposed = journal.entries.at(-1); + if (proposed === undefined || proposed.phase === current.current_phase) { + throw new TypeError("composed phase persistence proposal is invalid"); + } + const merged = appendComposedPhase( + current, proposed.phase, proposed.payload, proposed.recorded_at, + ); + await loaded.session.replace(current, merged); + latest = merged; }, }, journal: latest, @@ -156,6 +163,10 @@ export const runDurableComposedRun = async ( }); } catch (error) { if (latest === undefined) throw error; + if (session !== undefined) { + await session.assertCurrent(); + latest = session.current(); + } if (latest.current_phase === "completed") return completedComposedRunFromJournal(latest); const signal = error instanceof ComposedRunInterruption ? error.signal @@ -195,46 +206,4 @@ export const recoverComposedRun = async ( readonly expected_authority: ComposedJournalAuthorityExpectation; }, ): Promise => runDurableComposedRun(input); - -export interface ComposedRecoveryArguments extends ComposedJournalAuthorityExpectation { - readonly journal_path: string; -} - -export const parseComposedRecoveryArguments = ( - argv: readonly string[], -): ComposedRecoveryArguments => { - const [journalFlag, journalPath, runFlag, runId, authorityFlag, authorityDigest, ...extra] = argv; - if (journalFlag !== "--journal" || journalPath === undefined - || runFlag !== "--run-id" || runId === undefined - || authorityFlag !== "--authority-digest" || authorityDigest === undefined - || extra.length > 0 || !path.isAbsolute(journalPath) || path.normalize(journalPath) !== journalPath - || journalPath === path.parse(journalPath).root - || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(runId) - || !/^sha256:[a-f0-9]{64}$/u.test(authorityDigest)) { - throw new TypeError("usage: simfile recover --journal --run-id --authority-digest "); - } - return { authority_digest: authorityDigest, journal_path: journalPath, run_id: runId }; -}; - -/** Thin command seam for the exact recovery command emitted by recovery receipts. */ -export const runComposedRecoveryCommand = async (input: Readonly<{ - argv: readonly string[]; - configuration: ComposedRunConfiguration; - fault_injector?: ComposedRunFaultInjector; - now?: () => string; - ports: ComposedRunPorts; -}>): Promise => { - const [command, ...args] = input.argv; - const parsed = parseComposedRecoveryArguments(command === "recover" ? args : []); - return recoverComposedRun({ - configuration: input.configuration, - expected_authority: { - authority_digest: parsed.authority_digest, - run_id: parsed.run_id, - }, - fault_injector: input.fault_injector, - journal_path: parsed.journal_path, - now: input.now, - ports: input.ports, - }); -}; +export * from "./recoveryCommand.js"; diff --git a/src/compose/recoveryCommand.ts b/src/compose/recoveryCommand.ts new file mode 100644 index 0000000..d4b55ca --- /dev/null +++ b/src/compose/recoveryCommand.ts @@ -0,0 +1,52 @@ +import path from "node:path"; + +import type { ComposedJournalAuthorityExpectation } from "./journalSession.js"; +import { + recoverComposedRun, + type ComposedRunOutcome, +} from "./recovery.js"; +import type { ComposedRunConfiguration, ComposedRunPorts } from "./run.js"; +import type { ComposedRunFaultInjector } from "./types.js"; + +export interface ComposedRecoveryArguments extends ComposedJournalAuthorityExpectation { + readonly journal_path: string; +} + +export const parseComposedRecoveryArguments = ( + argv: readonly string[], +): ComposedRecoveryArguments => { + const [journalFlag, journalPath, runFlag, runId, authorityFlag, authorityDigest, ...extra] = argv; + if (journalFlag !== "--journal" || journalPath === undefined + || runFlag !== "--run-id" || runId === undefined + || authorityFlag !== "--authority-digest" || authorityDigest === undefined + || extra.length > 0 || !path.isAbsolute(journalPath) || path.normalize(journalPath) !== journalPath + || journalPath === path.parse(journalPath).root + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(runId) + || !/^sha256:[a-f0-9]{64}$/u.test(authorityDigest)) { + throw new TypeError("usage: simfile recover --journal --run-id --authority-digest "); + } + return { authority_digest: authorityDigest, journal_path: journalPath, run_id: runId }; +}; + +/** Thin command seam for the exact recovery command emitted by recovery receipts. */ +export const runComposedRecoveryCommand = async (input: Readonly<{ + argv: readonly string[]; + configuration: ComposedRunConfiguration; + fault_injector?: ComposedRunFaultInjector; + now?: () => string; + ports: ComposedRunPorts; +}>): Promise => { + const [command, ...args] = input.argv; + const parsed = parseComposedRecoveryArguments(command === "recover" ? args : []); + return recoverComposedRun({ + configuration: input.configuration, + expected_authority: { + authority_digest: parsed.authority_digest, + run_id: parsed.run_id, + }, + fault_injector: input.fault_injector, + journal_path: parsed.journal_path, + now: input.now, + ports: input.ports, + }); +}; diff --git a/src/compose/request-receipt.test.ts b/src/compose/request-receipt.test.ts index 9af03bb..2bef9db 100644 --- a/src/compose/request-receipt.test.ts +++ b/src/compose/request-receipt.test.ts @@ -30,7 +30,7 @@ const request = parseComposedRunRequest({ required_world_capabilities: [], run_id: "run-one", source_digest: sha("e"), - target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + target: { auth_profile: "test-auth-profile", selector: "local-test-target" }, version: "simfile.composed-run-request.v1", world: { artifact_manifest_digest: sha("f"), @@ -54,7 +54,7 @@ const terminal = () => createComposedTerminalReceipt({ fingerprint: `sha256:${"8".repeat(32)}`, handle: `opaque_${"9".repeat(16)}`, }, - selector: "gpu-4090", + selector: "local-test-target", }, topology: { activation_receipt_digest: sha("a"), @@ -79,6 +79,11 @@ test("live request requires the declared Phase 3 decision-claim capability hook" mode: "live", required_world_capabilities: [WORLD_DECISION_CLAIM_CAPABILITY], })); + assert.throws(() => parseComposedRunRequest({ + ...request, + mode: "lifecycle-replay-smoke", + required_world_capabilities: [WORLD_DECISION_CLAIM_CAPABILITY], + })); }); test("terminal receipt rejects tamper, cross-run, unclean completion, and secret shapes", () => { diff --git a/src/compose/smokeCommandReceipt.test.ts b/src/compose/smokeCommandReceipt.test.ts new file mode 100644 index 0000000..aebed58 --- /dev/null +++ b/src/compose/smokeCommandReceipt.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { runDurableComposedRun } from "./recovery.js"; +import { lifecycleRequest } from "./lifecycle.test-helper.js"; +import { createComposedRunHarness } from "./run.test-helper.js"; +import { WORLD_DECISION_CLAIM_CAPABILITY } from "./request.js"; +import { parseComposedTerminalReceipt } from "./receipt.js"; +import { + createComposedLifecycleReplaySmokeReceipt, + parseComposedLifecycleReplaySmokeReceipt, + serializeComposedLifecycleReplaySmokeReceipt, +} from "./smokeCommandReceipt.js"; +import { verifyComposedTerminalOutcome } from "./terminalOutcome.js"; + +const completed = async (mode: "dry-run" | "live") => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-smoke-receipt-")); + const request = lifecycleRequest({ + mode, + required_world_capabilities: [WORLD_DECISION_CLAIM_CAPABILITY], + }); + const harness = createComposedRunHarness(request); + let tick = 0; + const result = await runDurableComposedRun({ + configuration: harness.configuration, + journal_path: path.join(root, "journal.json"), + now: () => new Date(Date.UTC(2026, 7, 7, 12, 0, tick++)).toISOString(), + ports: harness.ports, + request, + }); + return { + journal: result.journal, + receipt: parseComposedTerminalReceipt(result.receipt), + }; +}; + +const replay = { + accepted_action_count: 0, + exact: true as const, + probe_sha256: "a".repeat(64), + run_id: "run-lifecycle", + terminal_state_sha256: "0".repeat(64), + terminal_tick: 4, + version: "simfile.composed-replay-receipt.v1" as const, +}; + +test("lifecycle/replay smoke receipt reports live action as not evaluated", async () => { + const lifecycle = await completed("live"); + assert.equal(verifyComposedTerminalOutcome( + lifecycle.journal, replay, + ).outcome_digest, `sha256:${"0".repeat(64)}`); + assert.throws(() => verifyComposedTerminalOutcome(lifecycle.journal, { + ...replay, terminal_state_sha256: "b".repeat(64), + }), + /does not match exact replay/u); + const receipt = createComposedLifecycleReplaySmokeReceipt({ + journal: lifecycle.journal, + lifecycle_receipt: lifecycle.receipt, + manifest_digest: `sha256:${"c".repeat(64)}`, + replay, + run_path: "/runs/run-lifecycle", + viewer: { state: "disabled" }, + }); + assert.deepEqual(parseComposedLifecycleReplaySmokeReceipt(receipt), receipt); + assert.equal(receipt.mode, "lifecycle-replay-smoke"); + assert.equal(receipt.lifecycle_replay_verdict, "passed"); + assert.deepEqual(receipt.live_agent_evidence, { state: "not_evaluated" }); + assert.equal("simulation_verdict" in receipt, false); + assert.equal(serializeComposedLifecycleReplaySmokeReceipt(receipt) + .trim().split("\n").length, 1); +}); + +test("smoke receipt rejects dry-run lifecycle and correlation or digest forgery", async () => { + const lifecycle = await completed("dry-run"); + assert.throws(() => createComposedLifecycleReplaySmokeReceipt({ + journal: lifecycle.journal, + lifecycle_receipt: lifecycle.receipt, + manifest_digest: `sha256:${"c".repeat(64)}`, + replay, + run_path: "/runs/run-lifecycle", + viewer: { state: "disabled" }, + }), /smoke completion proof/u); + + const smoke = await completed("live"); + const receipt = createComposedLifecycleReplaySmokeReceipt({ + journal: smoke.journal, + lifecycle_receipt: smoke.receipt, + manifest_digest: `sha256:${"c".repeat(64)}`, + replay, + run_path: "/runs/run-lifecycle", + viewer: { state: "disabled" }, + }); + assert.throws(() => parseComposedLifecycleReplaySmokeReceipt({ + ...receipt, + live_agent_evidence: { state: "passed" }, + })); + assert.throws(() => parseComposedLifecycleReplaySmokeReceipt({ + ...receipt, + exact_replay: { ...receipt.exact_replay, run_id: "other-run" }, + }), /digest|correlation/u); +}); diff --git a/src/compose/smokeCommandReceipt.ts b/src/compose/smokeCommandReceipt.ts new file mode 100644 index 0000000..63ccad0 --- /dev/null +++ b/src/compose/smokeCommandReceipt.ts @@ -0,0 +1,178 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { parseWorldSidecarReadiness } from "../world-artifact/readiness.js"; +import { + assertSecretFreeComposedJson, + canonicalComposedJson, + digestComposedJson, +} from "./json.js"; +import { parseComposedPhaseJournal } from "./journal.js"; +import { composedPhasePayload } from "./phase.js"; +import { parseComposedTerminalReceipt } from "./receipt.js"; +import { WORLD_DECISION_CLAIM_CAPABILITY } from "./request.js"; +import type { ComposedReplayReceipt } from "./replay.js"; +import { verifyComposedTerminalOutcome } from "./terminalOutcome.js"; + +export const COMPOSED_LIFECYCLE_REPLAY_SMOKE_MODE = + "lifecycle-replay-smoke" as const; +export const COMPOSED_LIFECYCLE_REPLAY_SMOKE_RECEIPT_VERSION = + "simfile.composed-lifecycle-replay-smoke-receipt.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const absolute = z.string().max(4_096).refine((value) => path.isAbsolute(value)); +const replay = z.object({ + accepted_action_count: z.number().int().min(0), + exact: z.literal(true), + probe_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + run_id: z.string().min(1), + terminal_state_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + terminal_tick: z.number().int().min(1), + version: z.literal("simfile.composed-replay-receipt.v1"), +}).strict(); +const moltnet = z.object({ + architecture: z.enum(["amd64", "arm64"]), + asset: z.string().min(1), + asset_sha256: digest, + capabilities: z.tuple([z.literal("pi-bridge")]), + release_version: z.string().min(1), + source_revision: z.string().regex(/^[a-f0-9]{40}$/u), + version: z.literal("spawnfile.moltnet-release-identity.v1"), +}).strict(); +const viewer = z.discriminatedUnion("state", [ + z.object({ state: z.literal("disabled") }).strict(), + z.object({ + state: z.literal("attached"), + url: z.string().url().regex(/^http:\/\/127\.0\.0\.1:/u), + }).strict(), + z.object({ + error: z.string().min(1).max(4_096), + state: z.literal("unavailable"), + }).strict(), +]); + +export const composedLifecycleReplaySmokeReceiptSchema = z.object({ + cleanup: z.object({ + receipt_digest: digest, + remaining_owned_resources: z.array(z.string()).length(0), + state: z.literal("cleaned"), + }).strict(), + evidence: z.record(z.string(), z.unknown()), + exact_replay: replay, + lifecycle_receipt_digest: digest, + lifecycle_replay_verdict: z.literal("passed"), + live_agent_evidence: z.object({ + state: z.literal("not_evaluated"), + }).strict(), + manifest_digest: digest, + mode: z.literal(COMPOSED_LIFECYCLE_REPLAY_SMOKE_MODE), + moltnet: moltnet.nullable(), + receipt_digest: digest, + run_id: z.string().min(1), + run_path: absolute, + status: z.literal("completed"), + target: z.record(z.string(), z.unknown()), + version: z.literal(COMPOSED_LIFECYCLE_REPLAY_SMOKE_RECEIPT_VERSION), + viewer, + world_claim: z.object({ + attested: z.literal(true), + identity: z.literal(WORLD_DECISION_CLAIM_CAPABILITY), + manifest_digest: digest, + }).strict(), +}).strict(); + +export type ComposedLifecycleReplaySmokeReceipt = z.infer< + typeof composedLifecycleReplaySmokeReceiptSchema +>; + +export const parseComposedLifecycleReplaySmokeReceipt = ( + raw: unknown, +): ComposedLifecycleReplaySmokeReceipt => { + assertSecretFreeComposedJson(raw); + const value = composedLifecycleReplaySmokeReceiptSchema.parse(raw); + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson( + COMPOSED_LIFECYCLE_REPLAY_SMOKE_RECEIPT_VERSION, + body, + )) { + throw new TypeError("composed lifecycle/replay smoke receipt digest is invalid"); + } + if (value.exact_replay.run_id !== value.run_id) { + throw new TypeError("composed lifecycle/replay smoke correlation is invalid"); + } + return Object.freeze(value); +}; + +export const createComposedLifecycleReplaySmokeReceipt = (input: Readonly<{ + journal: unknown; + lifecycle_receipt: unknown; + manifest_digest: string; + replay: ComposedReplayReceipt; + run_path: string; + viewer: z.infer; +}>): ComposedLifecycleReplaySmokeReceipt => { + const journal = parseComposedPhaseJournal(input.journal); + const lifecycle = parseComposedTerminalReceipt(input.lifecycle_receipt); + if (journal.request.mode !== "live" + || journal.current_phase !== "completed" + || lifecycle.run_id !== journal.request.run_id + || input.replay.run_id !== journal.request.run_id + || lifecycle.seal.state !== "sealed" + || lifecycle.cleanup.state !== "cleaned" + || lifecycle.verdict.state !== "valid") { + throw new TypeError("composed lifecycle/replay smoke completion proof is invalid"); + } + verifyComposedTerminalOutcome(journal, input.replay); + const organization = composedPhasePayload(journal, "organization_ready"); + const readiness = parseWorldSidecarReadiness( + composedPhasePayload(journal, "world_ready").readiness, + ); + const claim = readiness.capabilities?.find(({ identity }) => + identity === WORLD_DECISION_CLAIM_CAPABILITY); + if (claim === undefined + || !readiness.capability_manifest_digests.includes(claim.manifest_digest)) { + throw new TypeError("composed lifecycle/replay smoke claim is not attested"); + } + const body = { + cleanup: lifecycle.cleanup, + evidence: lifecycle.evidence, + exact_replay: input.replay, + lifecycle_receipt_digest: lifecycle.receipt_digest, + lifecycle_replay_verdict: "passed" as const, + live_agent_evidence: { state: "not_evaluated" as const }, + manifest_digest: input.manifest_digest, + mode: COMPOSED_LIFECYCLE_REPLAY_SMOKE_MODE, + moltnet: moltnet.nullable().parse(organization.moltnet_release), + run_id: lifecycle.run_id, + run_path: path.resolve(input.run_path), + status: "completed" as const, + target: lifecycle.target, + version: COMPOSED_LIFECYCLE_REPLAY_SMOKE_RECEIPT_VERSION, + viewer: input.viewer, + world_claim: { + attested: true as const, + identity: WORLD_DECISION_CLAIM_CAPABILITY, + manifest_digest: claim.manifest_digest, + }, + }; + return parseComposedLifecycleReplaySmokeReceipt({ + ...body, + receipt_digest: digestComposedJson( + COMPOSED_LIFECYCLE_REPLAY_SMOKE_RECEIPT_VERSION, + body, + ), + }); +}; + +export const serializeComposedLifecycleReplaySmokeReceipt = ( + receipt: ComposedLifecycleReplaySmokeReceipt, +): string => `${canonicalComposedJson( + parseComposedLifecycleReplaySmokeReceipt(receipt), +)}\n`; + +export const writeComposedLifecycleReplaySmokeReceipt = ( + receipt: ComposedLifecycleReplaySmokeReceipt, +): void => { + process.stdout.write(serializeComposedLifecycleReplaySmokeReceipt(receipt)); +}; diff --git a/src/compose/startup-organization.ts b/src/compose/startup-organization.ts index 0407207..4107728 100644 --- a/src/compose/startup-organization.ts +++ b/src/compose/startup-organization.ts @@ -1,9 +1,4 @@ -import { createHash } from "node:crypto"; - -import { z } from "zod"; -import { targetResourceReceiptSchema } from "../spawnfile/targetReceipts.js"; - -import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; +import { digestComposedJson } from "./json.js"; import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; import { commitComposedPhase, @@ -11,183 +6,32 @@ import { composedPhaseReached, type ComposedPhaseContext, } from "./phase.js"; +import { + verifyComposedOrganizationUpReceipt, + type ComposedOrganizationExpectation, + type ComposedOrganizationUpReceipt, +} from "./startupOrganizationReceipt.js"; -const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); -const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); -const opaque = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); -const readiness = z.object({ - code: z.literal("organization_ready"), - compile_fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), - run_id: runId, - state: z.literal("ready"), - unit_id: runId, - version: z.literal("spawnfile.organization-ready.v1"), - world_binding_digest: digest, -}).strict(); -const release = 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: 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"), -}).strict().superRefine((value, context) => { - if (value.asset !== `moltnet_linux_${value.architecture}.tar.gz`) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "Moltnet asset architecture is invalid", - path: ["asset"], - }); - } - const describedRevision = value.release_version.match(/-g([a-f0-9]{7,40})$/u)?.[1]; - if (describedRevision && !value.source_revision.startsWith(describedRevision)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "Moltnet release version does not describe its source revision", - path: ["source_revision"], - }); - } -}); -const handoff = z.object({ - binding_digest: digest, - deployment_handle: z.string().regex(/^sf-oh1-[a-f0-9]{64}$/u), - lifecycle_receipts: z.object({ - down: z.literal("spawnfile.down-receipt.v1"), - export: z.literal("spawnfile.export-index.v1"), - up: z.literal("spawnfile.up-receipt.v1"), - }).strict(), - network_attachment_handle: opaque, - run_id: runId, - selected_target_receipt_digest: digest, - version: z.literal("spawnfile.organization-handoff.v1"), -}).strict(); -const upReceipt = z.object({ - compiled_schedule: z.array(z.object({ agent: runId, cron: z.string().min(1) }).passthrough()), - deployment: z.object({ - container_ids: z.array(runId).min(1), - name: runId, - }).passthrough(), - engines: z.array(z.object({ agent: runId, engine: runId }).passthrough()).min(1), - fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), - moltnet_release: release, - organization_handoff: handoff, - organization_handoff_handle: opaque, - organization_ready: readiness.optional(), - readiness: z.object({ - moltnet_base_url: z.string().url(), - state: z.literal("running"), - }).passthrough(), - run_id: runId, - target_attachment: targetResourceReceiptSchema.optional(), - version: z.literal("spawnfile.up-receipt.v1"), -}).passthrough(); - -export type ComposedOrganizationUpReceipt = z.infer; - -export interface ComposedOrganizationExpectation { - readonly deployment_name: string; - readonly member_engines: Readonly>; - readonly moltnet_release: Readonly<{ - architecture: "amd64" | "arm64"; - asset_sha256: string; - release_version: string; - source_revision: string; - }>; - readonly selected_target_receipt_digest: string; - readonly unit_id: string; - readonly world_binding_digest: string; -} +export { + deriveComposedOrganizationDeploymentHandle, + verifyComposedOrganizationUpReceipt, + type ComposedOrganizationExpectation, + type ComposedOrganizationUpReceipt, +} from "./startupOrganizationReceipt.js"; export interface ComposedOrganizationStartupPort { - startOrganization(input: Readonly<{ - idempotency_key: string; - run_id: string; - signal: AbortSignal; - world_readiness_digest: string; - }>): Promise; - readOrganizationReadiness(input: Readonly<{ - up_receipt: ComposedOrganizationUpReceipt; - signal: AbortSignal; - }>): Promise; + startOrganization(input: Readonly<{ idempotency_key: string; run_id: string; + signal: AbortSignal; world_readiness_digest: string }>): Promise; + readOrganizationReadiness(input: Readonly<{ up_receipt: ComposedOrganizationUpReceipt; + signal: AbortSignal }>): Promise; } -export const deriveComposedOrganizationDeploymentHandle = ( - value: Omit, "deployment_handle" | "version">, -): string => { - const canonical = [ - "spawnfile.organization-handoff.v1\0", - value.run_id, - value.selected_target_receipt_digest, - value.network_attachment_handle, - value.binding_digest, - value.lifecycle_receipts.up, - value.lifecycle_receipts.export, - value.lifecycle_receipts.down, - ].join("\n"); - return `sf-oh1-${createHash("sha256").update(canonical, "utf8").digest("hex")}`; -}; - -const noCognitionCriterion = (value: unknown): void => { - if (value && typeof value === "object" && !Array.isArray(value)) { - for (const [key, nested] of Object.entries(value)) { - if (/(?:agent|participant).*(?:reply|response|turn|action)|(?:reply|response).*count/iu.test(key)) { - throw new TypeError("organization readiness contains an agent-response criterion"); - } - noCognitionCriterion(nested); - } - } else if (Array.isArray(value)) value.forEach(noCognitionCriterion); -}; - -export const verifyComposedOrganizationUpReceipt = (input: Readonly<{ - expectation: ComposedOrganizationExpectation; - raw: unknown; - require_ready: boolean; - run_id: string; -}>): ComposedOrganizationUpReceipt => { - assertSecretFreeComposedJson(input.raw); - noCognitionCriterion(input.raw); - const receipt = upReceipt.parse(input.raw); - const expectedEngines = Object.entries(input.expectation.member_engines) - .sort(([left], [right]) => left.localeCompare(right)); - const actualEngines = receipt.engines.map(({ agent, engine }) => [agent, engine] as const) - .sort(([left], [right]) => left.localeCompare(right)); - const releaseExpected = input.expectation.moltnet_release; - const { deployment_handle: _deploymentHandle, version: _handoffVersion, ...handoffBody } - = receipt.organization_handoff; - if (receipt.run_id !== input.run_id - || receipt.deployment.name !== input.expectation.deployment_name - || new Set(receipt.deployment.container_ids).size !== receipt.deployment.container_ids.length - || JSON.stringify(actualEngines) !== JSON.stringify(expectedEngines) - || receipt.moltnet_release.architecture !== releaseExpected.architecture - || receipt.moltnet_release.asset !== `moltnet_linux_${releaseExpected.architecture}.tar.gz` - || receipt.moltnet_release.asset_sha256 !== releaseExpected.asset_sha256 - || receipt.moltnet_release.release_version !== releaseExpected.release_version - || receipt.moltnet_release.source_revision !== releaseExpected.source_revision - || receipt.organization_handoff.run_id !== input.run_id - || receipt.organization_handoff.binding_digest !== input.expectation.world_binding_digest - || receipt.organization_handoff.selected_target_receipt_digest - !== input.expectation.selected_target_receipt_digest - || receipt.organization_handoff.deployment_handle - !== deriveComposedOrganizationDeploymentHandle(handoffBody)) { - throw new TypeError("composed organization receipt correlation is invalid"); - } - if (input.require_ready && (!receipt.organization_ready - || receipt.organization_ready.run_id !== input.run_id - || receipt.organization_ready.world_binding_digest !== input.expectation.world_binding_digest - || receipt.organization_ready.compile_fingerprint !== receipt.fingerprint - || receipt.organization_ready.unit_id !== input.expectation.unit_id)) { - throw new TypeError("composed organization readiness is invalid"); - } - return Object.freeze(receipt); -}; - const operationKey = (journal: ComposedPhaseJournal, operation: string): string => `idem_${digestComposedJson("simfile.composed-organization-operation.v1", { operation, request_digest: journal.request_digest, }).slice(7, 39)}`; -/** Starts the organization only after world-only readiness, then proves exact bindings. */ +/** Starts the organization only after world-only readiness, then proves bindings. */ export const startComposedOrganization = async (input: Readonly<{ context: ComposedPhaseContext; expectation: ComposedOrganizationExpectation; @@ -203,44 +47,28 @@ export const startComposedOrganization = async (input: Readonly<{ if (typeof worldReadinessDigest !== "string") { throw new TypeError("composed world readiness digest is unavailable"); } + const signal = input.signal ?? new AbortController().signal; if (!composedPhaseReached(journal, "organization_started")) { - const upReceiptValue = verifyComposedOrganizationUpReceipt({ - expectation: input.expectation, + const receipt = verifyComposedOrganizationUpReceipt({ expectation: input.expectation, raw: await input.port.startOrganization({ idempotency_key: operationKey(journal, "start_organization"), - run_id: journal.request.run_id, - signal: input.signal ?? new AbortController().signal, - world_readiness_digest: worldReadinessDigest, - }), - require_ready: false, - run_id: journal.request.run_id, - }); + run_id: journal.request.run_id, signal, world_readiness_digest: worldReadinessDigest, + }), require_ready: false, run_id: journal.request.run_id }); journal = await commitComposedPhase(journal, "organization_started", { - run_id: journal.request.run_id, - up_receipt: upReceiptValue, - up_receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", upReceiptValue), + run_id: journal.request.run_id, up_receipt: receipt, + up_receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", receipt), }, input.context); } if (!composedPhaseReached(journal, "organization_ready")) { - const started = verifyComposedOrganizationUpReceipt({ - expectation: input.expectation, + const started = verifyComposedOrganizationUpReceipt({ expectation: input.expectation, raw: composedPhasePayload(journal, "organization_started").up_receipt, - require_ready: false, - run_id: journal.request.run_id, - }); - const ready = verifyComposedOrganizationUpReceipt({ - expectation: input.expectation, - raw: await input.port.readOrganizationReadiness({ - signal: input.signal ?? new AbortController().signal, - up_receipt: started, - }), - require_ready: true, - run_id: journal.request.run_id, - }); + require_ready: false, run_id: journal.request.run_id }); + const ready = verifyComposedOrganizationUpReceipt({ expectation: input.expectation, + raw: await input.port.readOrganizationReadiness({ signal, up_receipt: started }), + require_ready: true, run_id: journal.request.run_id }); journal = await commitComposedPhase(journal, "organization_ready", { - moltnet_release: ready.moltnet_release, - organization_handoff: ready.organization_handoff, - readiness: ready.organization_ready, + moltnet_release: ready.moltnet_release ?? null, + organization_handoff: ready.organization_handoff, readiness: ready.organization_ready, receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", ready), run_id: journal.request.run_id, }, input.context); diff --git a/src/compose/startupOrganizationReceipt.ts b/src/compose/startupOrganizationReceipt.ts new file mode 100644 index 0000000..3d2442e --- /dev/null +++ b/src/compose/startupOrganizationReceipt.ts @@ -0,0 +1,123 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { targetResourceReceiptSchema } from "../spawnfile/targetReceipts.js"; +import { assertSecretFreeComposedJson } from "./json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const opaque = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const readiness = z.object({ code: z.literal("organization_ready"), + compile_fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), run_id: runId, + state: z.literal("ready"), unit_id: runId, + version: z.literal("spawnfile.organization-ready.v1"), world_binding_digest: digest }).strict(); +const release = 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: 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") }).strict() + .superRefine((value, context) => { + if (value.asset !== `moltnet_linux_${value.architecture}.tar.gz`) { + context.addIssue({ code: z.ZodIssueCode.custom, + message: "Moltnet asset architecture is invalid", path: ["asset"] }); + } + 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 release version does not describe its source revision", + path: ["source_revision"] }); + } + }); +const handoff = z.object({ binding_digest: digest, + deployment_handle: z.string().regex(/^sf-oh1-[a-f0-9]{64}$/u), + lifecycle_receipts: z.object({ down: z.literal("spawnfile.down-receipt.v1"), + export: z.literal("spawnfile.export-index.v1"), + up: z.literal("spawnfile.up-receipt.v1") }).strict(), + network_attachment_handle: opaque, run_id: runId, + selected_target_receipt_digest: digest, + version: z.literal("spawnfile.organization-handoff.v1") }).strict(); +const upReceipt = z.object({ + compiled_schedule: z.array(z.object({ agent: runId, cron: z.string().min(1) }).passthrough()), + deployment: z.object({ container_ids: z.array(runId).min(1), name: runId }).passthrough(), + engines: z.array(z.object({ agent: runId, engine: runId }).passthrough()).min(1), + fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), moltnet_release: release.optional(), + organization_handoff: handoff, organization_handoff_handle: opaque, + organization_ready: readiness.optional(), + readiness: z.object({ moltnet_base_url: z.string().url().nullable(), + state: z.literal("running") }).passthrough(), + run_id: runId, target_attachment: targetResourceReceiptSchema.optional(), + version: z.literal("spawnfile.up-receipt.v1") }).passthrough(); + +export type ComposedOrganizationUpReceipt = z.infer; +export interface ComposedOrganizationExpectation { + readonly deployment_name: string; + readonly member_engines: Readonly>; + readonly moltnet_release?: Readonly<{ architecture: "amd64" | "arm64"; + asset_sha256: string; release_version: string; source_revision: string }>; + readonly selected_target_receipt_digest: string; + readonly unit_id: string; + readonly world_binding_digest: string; +} + +export const deriveComposedOrganizationDeploymentHandle = ( + value: Omit, "deployment_handle" | "version">, +): string => `sf-oh1-${createHash("sha256").update([ + "spawnfile.organization-handoff.v1\0", value.run_id, + value.selected_target_receipt_digest, value.network_attachment_handle, + value.binding_digest, value.lifecycle_receipts.up, + value.lifecycle_receipts.export, value.lifecycle_receipts.down, +].join("\n"), "utf8").digest("hex")}`; + +const noCognitionCriterion = (value: unknown): void => { + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [key, nested] of Object.entries(value)) { + if (/(?:agent|participant).*(?:reply|response|turn|action)|(?:reply|response).*count/iu + .test(key)) throw new TypeError("organization readiness contains an agent-response criterion"); + noCognitionCriterion(nested); + } + } else if (Array.isArray(value)) value.forEach(noCognitionCriterion); +}; + +export const verifyComposedOrganizationUpReceipt = (input: Readonly<{ + expectation: ComposedOrganizationExpectation; + raw: unknown; + require_ready: boolean; + run_id: string; +}>): ComposedOrganizationUpReceipt => { + assertSecretFreeComposedJson(input.raw); noCognitionCriterion(input.raw); + const receipt = upReceipt.parse(input.raw); + const expectedEngines = Object.entries(input.expectation.member_engines).sort(); + const actualEngines = receipt.engines.map(({ agent, engine }) => [agent, engine]).sort(); + const expectedRelease = input.expectation.moltnet_release; + const { deployment_handle: _handle, version: _version, ...handoffBody } + = receipt.organization_handoff; + if (receipt.run_id !== input.run_id + || receipt.deployment.name !== input.expectation.deployment_name + || new Set(receipt.deployment.container_ids).size !== receipt.deployment.container_ids.length + || JSON.stringify(actualEngines) !== JSON.stringify(expectedEngines) + || (expectedRelease === undefined) !== (receipt.moltnet_release === undefined) + || (expectedRelease === undefined) !== (receipt.readiness.moltnet_base_url === null) + || expectedRelease !== undefined && ( + receipt.moltnet_release?.architecture !== expectedRelease.architecture + || receipt.moltnet_release.asset_sha256 !== expectedRelease.asset_sha256 + || receipt.moltnet_release.release_version !== expectedRelease.release_version + || receipt.moltnet_release.source_revision !== expectedRelease.source_revision) + || receipt.organization_handoff.run_id !== input.run_id + || receipt.organization_handoff.binding_digest !== input.expectation.world_binding_digest + || receipt.organization_handoff.selected_target_receipt_digest + !== input.expectation.selected_target_receipt_digest + || receipt.organization_handoff.deployment_handle + !== deriveComposedOrganizationDeploymentHandle(handoffBody)) { + throw new TypeError("composed organization receipt correlation is invalid"); + } + if (input.require_ready && (!receipt.organization_ready + || receipt.organization_ready.run_id !== input.run_id + || receipt.organization_ready.world_binding_digest !== input.expectation.world_binding_digest + || receipt.organization_ready.compile_fingerprint !== receipt.fingerprint + || receipt.organization_ready.unit_id !== input.expectation.unit_id)) { + throw new TypeError("composed organization readiness is invalid"); + } + return Object.freeze(receipt); +}; diff --git a/src/compose/superviseServices.test.ts b/src/compose/superviseServices.test.ts index 6fac193..897050b 100644 --- a/src/compose/superviseServices.test.ts +++ b/src/compose/superviseServices.test.ts @@ -10,6 +10,12 @@ const atTickOne = () => { return { ...lifecyclePhaseContext({ persisted }), journal: tickOneLifecycleJournal() }; }; +const waitUntilAborted = (signal: AbortSignal): Promise => + new Promise((_resolve, reject) => { + if (signal.aborted) { reject(signal.reason); return; } + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + describe("service-only composed supervision", () => { it("accepts world terminal truth without consulting behavior", async () => { const { context, journal } = atTickOne(); @@ -33,7 +39,7 @@ describe("service-only composed supervision", () => { const { context, journal, persisted } = atTickOne(); await assert.rejects(superviseComposedWorld({ context, expected_terminal_tick: 40, journal, operator_timeout_ms: 5, - port: { waitForWorldTerminal: () => new Promise(() => undefined) }, + port: { waitForWorldTerminal: ({ signal }) => waitUntilAborted(signal) }, }), /operator timeout/u); assert.equal(persisted.at(-1)?.current_phase, "running"); }); @@ -43,7 +49,7 @@ describe("service-only composed supervision", () => { const controller = new AbortController(); const pending = superviseComposedWorld({ context, expected_terminal_tick: 40, journal, operator_timeout_ms: 1_000, - port: { waitForWorldTerminal: () => new Promise(() => undefined) }, + port: { waitForWorldTerminal: ({ signal }) => waitUntilAborted(signal) }, signal: controller.signal, }); controller.abort(new Error("operator interrupted")); diff --git a/src/compose/supervision.test.ts b/src/compose/supervision.test.ts index b24bf97..3463e1f 100644 --- a/src/compose/supervision.test.ts +++ b/src/compose/supervision.test.ts @@ -32,6 +32,30 @@ const fakePort = (journal: ComposedPhaseJournal, terminalTick = 4) => { return { calls, port }; }; +const pollingPort = () => { + const state = { aborts: 0, polls: 0, settled: false }; + let markStarted!: () => void; + const started = new Promise((resolve) => { markStarted = resolve; }); + const port: ComposedSupervisionPort = { + waitForWorldTerminal: ({ signal }) => new Promise((_resolve, reject) => { + markStarted(); + const poll = setInterval(() => { state.polls += 1; }, 1); + signal.addEventListener("abort", () => { + state.aborts += 1; + clearInterval(poll); + queueMicrotask(() => { + state.settled = true; + reject(signal.reason); + }); + }, { once: true }); + }), + }; + return { port, started, state }; +}; + +const pause = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + test("world supervision advances an exact tick horizon without cognition", async () => { const initial = tickOneLifecycleJournal(); const fake = fakePort(initial); @@ -106,3 +130,52 @@ test("world supervision rejects early, stale, cross-run, and forged terminal pro port: forged, }), /digest/u); }); + +test("world supervision timeout aborts and quiesces terminal polling", async () => { + const polling = pollingPort(); + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: tickOneLifecycleJournal(), + operator_timeout_ms: 5, + port: polling.port, + }), /operator timeout/u); + assert.equal(polling.state.aborts, 1); + assert.equal(polling.state.settled, true); + const pollsAtReturn = polling.state.polls; + await pause(10); + assert.equal(polling.state.polls, pollsAtReturn); +}); + +test("world supervision abort signal quiesces terminal polling before rejection", async () => { + const polling = pollingPort(); + const controller = new AbortController(); + const supervision = superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: tickOneLifecycleJournal(), + port: polling.port, + signal: controller.signal, + }); + await polling.started; + controller.abort(new Error("operator interrupted supervision")); + await assert.rejects(supervision, /operator interrupted supervision/u); + assert.equal(polling.state.aborts, 1); + assert.equal(polling.state.settled, true); + const pollsAtReturn = polling.state.polls; + await pause(10); + assert.equal(polling.state.polls, pollsAtReturn); +}); + +test("world supervision reports an uncooperative port within a second bound", async () => { + const started = Date.now(); + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: tickOneLifecycleJournal(), + operator_timeout_ms: 5, + port: { waitForWorldTerminal: async () => new Promise(() => undefined) }, + quiescence_timeout_ms: 5, + }), /failed to quiesce/u); + assert.ok(Date.now() - started < 100, "uncooperative port exceeded its quiescence bound"); +}); diff --git a/src/compose/supervision.ts b/src/compose/supervision.ts index 9a0a881..0d9aaa5 100644 --- a/src/compose/supervision.ts +++ b/src/compose/supervision.ts @@ -13,6 +13,7 @@ import { composedPhaseReached, type ComposedPhaseContext, } from "./phase.js"; +import { waitForComposedTerminal } from "./supervisionTimeout.js"; export const COMPOSED_RUNNING_RECEIPT_VERSION = "simfile.composed-running.v1" as const; export const COMPOSED_WORLD_TERMINAL_VERSION = "simfile.composed-world-terminal.v1" as const; @@ -68,39 +69,6 @@ export interface ComposedSupervisionPort { }>): Promise; } -const defaultOperatorTimeoutMs = 900_000; - -const waitForTerminal = async (input: Readonly<{ - operation: Promise; - operator_timeout_ms: number; - signal: AbortSignal; -}>): Promise => { - if (!Number.isSafeInteger(input.operator_timeout_ms) - || input.operator_timeout_ms < 1 || input.operator_timeout_ms > 86_400_000) { - throw new TypeError("composed operator timeout is invalid"); - } - if (input.signal.aborted) throw input.signal.reason; - let timeout: ReturnType | undefined; - let onAbort: (() => void) | undefined; - try { - return await Promise.race([ - input.operation, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error( - "composed world supervision reached the operator timeout", - )), input.operator_timeout_ms); - }), - new Promise((_resolve, reject) => { - onAbort = () => reject(input.signal.reason); - input.signal.addEventListener("abort", onAbort, { once: true }); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - if (onAbort !== undefined) input.signal.removeEventListener("abort", onAbort); - } -}; - /** Supervises world time only; participant traffic is outside this boundary. */ export const superviseComposedWorld = async (input: Readonly<{ context: ComposedPhaseContext; @@ -108,6 +76,8 @@ export const superviseComposedWorld = async (input: Readonly<{ journal: unknown; operator_timeout_ms?: number; port: ComposedSupervisionPort; + /** Internal contract-test seam; production allows five seconds to quiesce. */ + quiescence_timeout_ms?: number; signal?: AbortSignal; }>): Promise => { let journal = parseComposedPhaseJournal(input.journal); @@ -138,11 +108,14 @@ export const superviseComposedWorld = async (input: Readonly<{ ); if (!composedPhaseReached(journal, "terminal")) { const signal = input.signal ?? new AbortController().signal; - const terminal = parseComposedWorldTerminalReceipt(await waitForTerminal({ - operation: input.port.waitForWorldTerminal({ - expected_terminal_tick: input.expected_terminal_tick, running, signal, + const terminal = parseComposedWorldTerminalReceipt(await waitForComposedTerminal({ + operation: (operationSignal) => input.port.waitForWorldTerminal({ + expected_terminal_tick: input.expected_terminal_tick, + running, + signal: operationSignal, }), - operator_timeout_ms: input.operator_timeout_ms ?? defaultOperatorTimeoutMs, + operator_timeout_ms: input.operator_timeout_ms ?? 900_000, + quiescence_timeout_ms: input.quiescence_timeout_ms ?? 5_000, signal, })); if (terminal.run_id !== journal.request.run_id diff --git a/src/compose/supervisionTimeout.ts b/src/compose/supervisionTimeout.ts new file mode 100644 index 0000000..9f46dfc --- /dev/null +++ b/src/compose/supervisionTimeout.ts @@ -0,0 +1,72 @@ +const boundedTimeout = (value: number, label: string, maximum: number): number => { + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new TypeError(`composed ${label} timeout is invalid`); + } + return value; +}; + +const awaitOperationQuiescence = async ( + operation: Promise, + timeoutMs: number, +): Promise => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + operation.then(() => undefined, () => undefined), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error( + "composed terminal port did not quiesce after abort", + )), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +}; + +export const waitForComposedTerminal = async (input: Readonly<{ + operation(signal: AbortSignal): Promise; + operator_timeout_ms: number; + quiescence_timeout_ms: number; + signal: AbortSignal; +}>): Promise => { + boundedTimeout(input.operator_timeout_ms, "operator", 86_400_000); + boundedTimeout(input.quiescence_timeout_ms, "quiescence", 60_000); + if (input.signal.aborted) throw input.signal.reason; + const operationController = new AbortController(); + let cancelled = false; + let cancellationReason: unknown; + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + const cancel = (reason: unknown): void => { + if (cancelled) return; + cancelled = true; + cancellationReason = reason; + reject(reason); + operationController.abort(reason); + }; + timeout = setTimeout(() => cancel(new Error( + "composed world supervision reached the operator timeout", + )), input.operator_timeout_ms); + onAbort = () => cancel(input.signal.reason); + input.signal.addEventListener("abort", onAbort, { once: true }); + }); + const operation = Promise.resolve().then(() => input.operation(operationController.signal)); + try { + return await Promise.race([operation, cancellation]); + } catch (error) { + if (!cancelled) throw error; + try { await awaitOperationQuiescence(operation, input.quiescence_timeout_ms); } + catch (quiescenceError) { + throw new AggregateError( + [cancellationReason, quiescenceError], + "composed terminal cancellation failed to quiesce its port", + ); + } + throw cancellationReason; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (onAbort !== undefined) input.signal.removeEventListener("abort", onAbort); + } +}; diff --git a/src/compose/terminalOutcome.ts b/src/compose/terminalOutcome.ts new file mode 100644 index 0000000..0ff45c6 --- /dev/null +++ b/src/compose/terminalOutcome.ts @@ -0,0 +1,24 @@ +import type { ComposedReplayReceipt } from "./replay.js"; +import { parseComposedPhaseJournal } from "./journal.js"; +import { composedPhasePayload } from "./phase.js"; +import { + parseComposedWorldTerminalReceipt, + type ComposedWorldTerminalReceipt, +} from "./supervision.js"; + +/** Binds the public terminal signal to the exact replayed terminal-state bytes. */ +export const verifyComposedTerminalOutcome = ( + rawJournal: unknown, + replay: ComposedReplayReceipt, +): ComposedWorldTerminalReceipt => { + const journal = parseComposedPhaseJournal(rawJournal); + const terminal = parseComposedWorldTerminalReceipt( + composedPhasePayload(journal, "terminal").receipt, + ); + if (terminal.run_id !== replay.run_id + || terminal.terminal_tick !== replay.terminal_tick + || terminal.outcome_digest !== `sha256:${replay.terminal_state_sha256}`) { + throw new TypeError("composed terminal outcome does not match exact replay"); + } + return terminal; +}; diff --git a/src/moltnet/machine/CLAUDE.md b/src/moltnet/machine/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/moltnet/machine/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/observe/AGENTS.md b/src/observe/AGENTS.md index 483aaf9..3b24b72 100644 --- a/src/observe/AGENTS.md +++ b/src/observe/AGENTS.md @@ -3,9 +3,9 @@ This folder implements `simfile observe ` (Decision 21 / `contracts.md`'s Slice B compose-and-observe pipeline). It is a **pure file-reading + reconciliation** module: no Docker, no compile, no runtime auth — see the package charter in -`ecosystem/simfile/AGENTS.md`. It consumes a sealed run directory and never imports +the repository `AGENTS.md`. It consumes a sealed run directory and never imports Spawnfile internals; the only cross-repo dependency is the narrow shared package -`@noopolis/stele` (`ecosystem/stele`), which contracts.md permits explicitly. +`@noopolis/stele`, which the contracts permit explicitly. ## Files diff --git a/src/observe/observe.ts b/src/observe/observe.ts index b88a5ac..a64ccf7 100644 --- a/src/observe/observe.ts +++ b/src/observe/observe.ts @@ -45,7 +45,7 @@ const loadRunManifest = async (runDir: string): Promise => { * with `@noopolis/stele` (no stitching, ever — an incomplete chain is * flagged, not synthesized), and emits the `simfile.observe.v1` report. * Pure file-reading + reconciliation: no Docker, no compile, no runtime - * auth (this package's charter, `ecosystem/simfile/AGENTS.md`). + * auth (this package's charter, the repository `AGENTS.md`). */ export const runObserve = async (runDir: string): Promise => { const manifest = await loadRunManifest(runDir); diff --git a/src/run/AGENTS.md b/src/run/AGENTS.md index 5203184..fc332bb 100644 --- a/src/run/AGENTS.md +++ b/src/run/AGENTS.md @@ -6,9 +6,10 @@ the linked-project lifecycle composer. ## Boundaries -- Follow `docs/DESIGN.md` and, when working in the parent Spawnfile repository, - its `specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md`; that upstream specification is - not included in a standalone Simfile checkout. +- Follow `docs/DESIGN.md`. Cross-product lifecycle boundaries use documented + Spawnfile CLI contracts and Simfile's own public capability probe; never ask + Spawnfile for a Simfile-specific compatibility profile. This standalone + repository never assumes a parent checkout. - Keep the trace path byte-compatible. Dispatch above `writeRunRecord`; do not change `runSimfileTrace` or the trace record writer. - A sealed run action source is one scripted/non-live tick notification, not diff --git a/src/sims/AGENTS.md b/src/sims/AGENTS.md new file mode 100644 index 0000000..355e966 --- /dev/null +++ b/src/sims/AGENTS.md @@ -0,0 +1,11 @@ +# Legacy Simulation Utility Guide + +This folder contains bounded simulation-fixture utilities retained for public +package compatibility. It is not the production CLI composition path. + +- Do not add new product behavior here without a production importer and an + explicit ownership decision. +- Keep utilities deterministic, timer-bounded, and free of checkout-relative + paths or service lifecycle ownership. +- Prefer current `src/run/`, `src/compose/`, and `src/world/` authorities for + new implementation work. diff --git a/src/sims/CLAUDE.md b/src/sims/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/sims/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/spawnfile/AGENTS.md b/src/spawnfile/AGENTS.md index 90953e7..c13c67e 100644 --- a/src/spawnfile/AGENTS.md +++ b/src/spawnfile/AGENTS.md @@ -3,29 +3,32 @@ This folder is Simfile's generic public-neutral boundary for Spawnfile lifecycle subprocesses and the documented versioned JSON receipts they emit. -- `cli.ts` shells only the documented high-level composed preparation command, - `spawnfile up`, `spawnfile artifacts export`, and `spawnfile down` through - Node. Composed preparation supplies private target configuration only on - stdin, captures stdout separately from stderr, and removes its temporary - secret-free request file. This boundary must never import Spawnfile - TypeScript internals or invoke Docker directly. -- `process.ts` owns bounded subprocess execution, in-flight cancellation, and - nonsecret target-config producer argv execution; private bytes remain in memory. +- `cli.ts` is the CLI barrel; the high-level, lifecycle, evidence, and target + command wrappers are split across the adjacent `*Cli.ts` modules. This + boundary must never import Spawnfile TypeScript internals or invoke Docker + directly. Its legacy stdin helpers are not a composed product path. +- `process.ts` owns bounded subprocess execution, while `processTree.ts` and + `executableIdentity.ts` own quiescence and exact bootstrap executable identity. - `receipts.ts` owns additive-tolerant validation of the public JSON wire receipts. Keep the SHA-256 validation, strict identity/correlation checks, and secret-shape rejection; do not substitute Spawnfile's internal schemas. - `preparationReceipt.ts` owns Simfile's independent additive-tolerant parser for the documented Spawnfile composed-preparation request/receipt pair. -- `targetReceipts.ts` independently validates public target-operation, readiness, - and bounded public-artifact wire receipts. +- `targetReceipts.ts` is the receipt barrel; `targetResourceReceipts.ts`, + `targetWorldReceipts.ts`, and `targetPublicArtifact.ts` independently validate + public target-operation, readiness, and bounded public-artifact receipts. - `evidenceInventory.ts` derives B14 only from Spawnfile's byte-derived, source-bound public evidence export index and rejects unknown or incomplete inventories. - `worldEvidenceArchive.ts` validates that byte-derived index against the private canonical target USTAR export and atomically materializes it for sealed-record assembly. -- `productionTarget.ts` recreates private config in memory for each public target call and - gates both producer and Spawnfile subprocesses on the pinned journal session. -- `productionPorts.ts` maps the composed lifecycle to documented Spawnfile CLI operations; - `productionOrganizationPorts.ts` owns the organization-start port slice. +- `targetBootstrap.ts` consumes the exact resolver/selection/container-bundle + receipts under the v2 bootstrap journal. `composedTargetProvider.ts` is the + resolver-backed, journal-aware provider; `targetOperationLookup.ts` and + `lifecycleLookup.ts` independently parse typed recovery observations. +- `productionTarget.ts` gates target requests on the pinned journal session and + delegates them only through that provider seam. `productionPorts.ts` is the + production barrel; the adjacent `production*Ports.ts` modules map the world, + topology, organization, finalization, and cleanup boundaries. - `productionTerminal.ts` polls only the bounded world-owned public terminal artifact. - `productionViewerProjection.ts` polls one declared world-owned public viewer artifact through the same verified read-only target operation. Its bound @@ -37,5 +40,5 @@ lifecycle subprocesses and the documented versioned JSON receipts they emit. documented generic lifecycle and receipt API; no domain or provider-client surface belongs here. -Keep files below 400 lines, use named exports, and place tests beside the +Keep changed production files at or below 200 lines, use named exports, and place tests beside the boundary they prove. diff --git a/src/spawnfile/bootstrapCli.test.ts b/src/spawnfile/bootstrapCli.test.ts index b28c930..a936098 100644 --- a/src/spawnfile/bootstrapCli.test.ts +++ b/src/spawnfile/bootstrapCli.test.ts @@ -6,113 +6,34 @@ import test from "node:test"; import { runSpawnfileCompile, - runSpawnfileDeriveBundlePolicy, - runSpawnfilePrepareContainerBundle, runSpawnfileProvisionCredentials, runSpawnfileRevokeCredentialSource, - runSpawnfileSelectTarget, } from "./bootstrapCli.js"; +import { captureBootstrapLocalExecutableIdentity } from "./process.js"; -const sha = (character: string): `sha256:${string}` => - `sha256:${character.repeat(64)}`; -const opaque = (character: string): `opaque_${string}` => - `opaque_${character.repeat(16)}`; +const opaque = (character: string): `opaque_${string}` => `opaque_${character.repeat(16)}`; const writeFakeSpawnfile = async (root: string): Promise => { const file = path.join(root, "fake-spawnfile.mjs"); await writeFile(file, `#!/usr/bin/env node -import { createHash } from "node:crypto"; import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; -const argv = process.argv.slice(2); -let stdin = ""; process.stdin.setEncoding("utf8"); -for await (const chunk of process.stdin) stdin += chunk; -const canonical = (value) => Array.isArray(value) - ? "[" + value.map(canonical).join(",") + "]" - : value !== null && typeof value === "object" - ? "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + canonical(value[key])).join(",") + "}" - : JSON.stringify(value); -const digest = (domain, value) => "sha256:" + createHash("sha256") - .update("spawnfile.target-local-container-bundle." + domain + ".v1\\0") - .update(canonical(value)).digest("hex"); -const requestFile = argv.at(-1); -const request = requestFile?.endsWith(".json") - ? JSON.parse(await readFile(requestFile, "utf8")) : undefined; -await appendFile(process.env.CAPTURE_FILE, JSON.stringify({ argv, stdin, - request_size: requestFile?.endsWith(".json") ? (await readFile(requestFile)).byteLength : null }) + "\\n"); -let output; -if (argv[0] === "compile") { - const out = argv[argv.indexOf("--out") + 1]; await mkdir(out, { recursive: true }); - await writeFile(new URL("spawnfile-report.json", "file://" + out + "/"), JSON.stringify({ compiled: true })); -} else if (argv[3] === "select_target") output = { - fingerprint: "sha256:" + "1".repeat(32), handle: "opaque_" + "a".repeat(16), - version: "spawnfile.target-resource.selected-target.v1", -}; -else if (argv[3] === "derive_container_bundle_policy") output = { - build_policy_digest: "sha256:" + "2".repeat(64), - platform_digest: "sha256:" + "3".repeat(64), - version: "spawnfile.target-local-container-bundle-policy.v1", -}; -else if (argv[3] === "prepare_container_bundle") { - const body = { - archive_digest: request.archive_digest, artifact_digest: request.artifact_digest, - build_policy_digest: request.build_policy_digest, bundle_digest: request.bundle_digest, - launcher_digest: request.launcher_digest, mapping_handle: "opaque_" + "b".repeat(16), - network_alias: request.network_alias, operation_handle: "opaque_" + "c".repeat(16), - platform: request.platform, platform_digest: request.platform_digest, - request_digest: digest("request", request), selected_target: request.selected_target, - version: "spawnfile.target-local-container-bundle.prepare-receipt.v1", - }; - output = { ...body, receipt_digest: process.env.BAD_RECEIPT === "1" - ? "sha256:" + "0".repeat(64) : digest("receipt", body) }; -} else if (argv[0] === "auth" && argv[1] === "provision") output = { - credentials: [{ env: "WORLD_TOKEN", name: "world_token", scope: "world", - source_handle: "opaque_" + "d".repeat(16) }], - env_file_digest: "sha256:" + "4".repeat(64), phases: ["author", "grant"], - run_id: "run-one", scope: "world", - version: "spawnfile.auth.credential-provisioning.receipt.v1", - world_bindings_digest: "sha256:" + "5".repeat(64), -}; else if (argv[0] === "auth" && argv[1] === "target-secret") output = { - kind: argv[2], source_handle: request.source_handle, - version: "spawnfile.auth.target-secret.receipt.v1", -}; -if (process.env.FAIL_REVOKE_GRANT === "1" && argv[2] === "revoke-grant") output.kind = "wrong"; -if (output !== undefined) process.stdout.write(JSON.stringify(output) + "\\n"); +const argv = process.argv.slice(2); const request = argv.at(-1)?.endsWith(".json") + ? JSON.parse(await readFile(argv.at(-1), "utf8")) : undefined; +await appendFile(process.env.CAPTURE_FILE, JSON.stringify({ argv, request }) + "\\n"); +if (argv[0] === "compile") { const out = argv[argv.indexOf("--out") + 1]; await mkdir(out, { recursive: true }); await writeFile(out + "/spawnfile-report.json", JSON.stringify({ compiled: true })); } +if (argv[0] === "auth" && argv[1] === "provision") process.stdout.write(JSON.stringify({ credentials: [{ env: "WORLD_TOKEN", name: "world_token", scope: "world", source_handle: "opaque_${"d".repeat(16)}" }], env_file_digest: "sha256:${"4".repeat(64)}", phases: ["author", "grant"], run_id: "run-one", scope: "world", version: "spawnfile.auth.credential-provisioning.receipt.v1", world_bindings_digest: "sha256:${"5".repeat(64)}" }) + "\\n"); +if (argv[0] === "auth" && argv[1] === "target-secret") process.stdout.write(JSON.stringify({ kind: argv[2], source_handle: request.source_handle, version: "spawnfile.auth.target-secret.receipt.v1" }) + "\\n"); `, { mode: 0o700 }); return file; }; -test("bootstrap wrappers preserve public Spawnfile boundaries and a large bundle envelope", async () => { +test("bootstrap wrappers retain compile and credential cleanup without a target-helper ABI", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-cli-")); try { const capture = path.join(root, "capture.jsonl"); - const context = { env: { ...process.env, CAPTURE_FILE: capture }, - spawnfileBin: await writeFakeSpawnfile(root) }; - const targetConfig = new TextEncoder().encode("PRIVATE_TARGET_CONFIG"); - assert.deepEqual(await runSpawnfileSelectTarget(context, { - request: { operation: "select" }, target_config_stdin: targetConfig, - }), { fingerprint: `sha256:${"1".repeat(32)}`, handle: opaque("a"), - version: "spawnfile.target-resource.selected-target.v1" }); - assert.deepEqual(await runSpawnfileDeriveBundlePolicy(context, { architecture: "amd64" }), { - build_policy_digest: sha("2"), platform_digest: sha("3"), - version: "spawnfile.target-local-container-bundle-policy.v1", - }); - const archive = Buffer.alloc(512 * 1024, 7); - const request = { - archive_base64: archive.toString("base64"), archive_digest: sha("6"), - archive_entries: ["entrypoint.mjs"], artifact_digest: sha("7"), - build_policy_digest: sha("2"), bundle_digest: sha("8"), - entrypoint: "entrypoint.mjs", idempotency_key: `idem_${"a".repeat(16)}`, - launcher_digest: sha("9"), network_alias: "world", platform: { - architecture: "amd64" as const, os: "linux" as const, - }, platform_digest: sha("3"), selected_target: { - fingerprint: `sha256:${"1".repeat(32)}`, handle: opaque("a"), - }, version: "spawnfile.target-local-container-bundle.prepare-request.v1" as const, - }; - const bundle = await runSpawnfilePrepareContainerBundle(context, { - request, target_config_stdin: targetConfig, - }); - assert.equal(bundle.bundle_digest, request.bundle_digest); - assert.equal(bundle.selected_target.handle, request.selected_target.handle); + const spawnfileBin = await writeFakeSpawnfile(root); + const context = { bootstrapLocalExecutableIdentity: await captureBootstrapLocalExecutableIdentity(spawnfileBin), + env: { ...process.env, CAPTURE_FILE: capture }, spawnfileBin }; const envFile = path.join(root, "organization.env"); const grantsFile = path.join(root, "grants.json"); const bindingsFile = path.join(root, "bindings.json"); @@ -121,63 +42,16 @@ test("bootstrap wrappers preserve public Spawnfile boundaries and a large bundle env_file: envFile, request: { version: "request" }, resolved_grants_file: grantsFile, world_bindings_file: bindingsFile, }); - assert.equal(auth.credentials[0]?.source_handle, opaque("d")); await runSpawnfileRevokeCredentialSource(context, { source_handle: opaque("d") }); const compiled = path.join(root, "compiled"); assert.deepEqual(await runSpawnfileCompile(context, { compiled_output_directory: compiled, organization_path: "/project/Spawnfile", }), { compiled: true }); + assert.equal(auth.credentials[0]?.source_handle, opaque("d")); const calls = (await readFile(capture, "utf8")).trim().split("\n") - .map((line) => JSON.parse(line) as { argv: string[]; request_size: number; stdin: string }); - assert.deepEqual(calls.map(({ argv }) => argv.slice(0, 4)), [ - ["target", "--config", "-", "select_target"], - ["target", "--config", "-", "derive_container_bundle_policy"], - ["target", "--config", "-", "prepare_container_bundle"], - ["auth", "provision", calls[3]!.argv[2]!, "--env-file"], - ["auth", "target-secret", "revoke-grant", calls[4]!.argv[3]!], - ["auth", "target-secret", "revoke-version", calls[5]!.argv[3]!], - ["compile", "/project/Spawnfile", "--out", compiled], + .map((line) => JSON.parse(line) as { argv: string[] }); + assert.deepEqual(calls.map(({ argv }) => argv.slice(0, 2)), [ + ["auth", "provision"], ["auth", "target-secret"], ["auth", "target-secret"], ["compile", "/project/Spawnfile"], ]); - assert.equal(calls[0]!.stdin, "PRIVATE_TARGET_CONFIG"); - assert.equal(calls[1]!.stdin, "{}"); - assert.equal(calls[2]!.stdin, "PRIVATE_TARGET_CONFIG"); - assert.ok(calls[2]!.request_size > 512 * 1024); - } finally { await rm(root, { force: true, recursive: true }); } -}); - -test("bundle wrapper rejects receipt correlation drift without exposing target config", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-drift-")); - try { - const context = { env: { ...process.env, BAD_RECEIPT: "1", - CAPTURE_FILE: path.join(root, "capture.jsonl") }, spawnfileBin: await writeFakeSpawnfile(root) }; - const request = { - archive_base64: Buffer.from("archive").toString("base64"), archive_digest: sha("6"), - archive_entries: ["entrypoint.mjs"], artifact_digest: sha("7"), - build_policy_digest: sha("2"), bundle_digest: sha("8"), entrypoint: "entrypoint.mjs", - idempotency_key: `idem_${"a".repeat(16)}`, launcher_digest: sha("9"), - network_alias: "world", platform: { architecture: "amd64", os: "linux" }, - platform_digest: sha("3"), selected_target: { - fingerprint: `sha256:${"1".repeat(32)}`, handle: opaque("a"), - }, version: "spawnfile.target-local-container-bundle.prepare-request.v1", - } as const; - await assert.rejects(runSpawnfilePrepareContainerBundle(context, { - request, target_config_stdin: new TextEncoder().encode("PRIVATE_TARGET_CONFIG"), - }), (error: Error) => /correlation is invalid/u.test(error.message) - && !error.message.includes("PRIVATE_TARGET_CONFIG")); - } finally { await rm(root, { force: true, recursive: true }); } -}); - -test("credential cleanup attempts version revocation after a grant-revocation failure", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-revoke-")); - try { - const capture = path.join(root, "capture.jsonl"); - const context = { env: { ...process.env, CAPTURE_FILE: capture, - FAIL_REVOKE_GRANT: "1" }, spawnfileBin: await writeFakeSpawnfile(root) }; - await assert.rejects(runSpawnfileRevokeCredentialSource(context, { - source_handle: opaque("d"), - }), /source revocation is incomplete/u); - const calls = (await readFile(capture, "utf8")).trim().split("\n") - .map((line) => (JSON.parse(line) as { argv: string[] }).argv[2]); - assert.deepEqual(calls, ["revoke-grant", "revoke-version"]); } finally { await rm(root, { force: true, recursive: true }); } }); diff --git a/src/spawnfile/bootstrapCli.ts b/src/spawnfile/bootstrapCli.ts index c7575e8..1c0fb10 100644 --- a/src/spawnfile/bootstrapCli.ts +++ b/src/spawnfile/bootstrapCli.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; -import { Buffer } from "node:buffer"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -7,38 +5,10 @@ import path from "node:path"; import { z } from "zod"; import { canonicalComposedJson } from "../compose/json.js"; -import { runSpawnfileProcess, type SpawnfileCliContext } from "./process.js"; +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); -const selectedTargetSchema = z.object({ - fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), - handle, - version: z.literal("spawnfile.target-resource.selected-target.v1"), -}).strict(); -const selectedTargetIdentitySchema = selectedTargetSchema.omit({ version: true }); -const policySchema = z.object({ - build_policy_digest: digest, - platform_digest: digest, - version: z.literal("spawnfile.target-local-container-bundle-policy.v1"), -}).strict(); -const bundleReceiptSchema = z.object({ - archive_digest: digest, - artifact_digest: digest, - build_policy_digest: digest, - bundle_digest: digest, - launcher_digest: digest, - mapping_handle: handle, - network_alias: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/u), - operation_handle: handle, - platform: z.object({ architecture: z.enum(["amd64", "arm64"]), - os: z.literal("linux") }).strict(), - platform_digest: digest, - receipt_digest: digest, - request_digest: digest, - selected_target: selectedTargetIdentitySchema, - version: z.literal("spawnfile.target-local-container-bundle.prepare-receipt.v1"), -}).strict(); const provisionReceiptSchema = z.object({ credentials: z.array(z.object({ env: z.string(), name: z.string(), scope: z.string(), source_handle: handle }).strict()).min(1).max(64), @@ -50,46 +20,15 @@ const provisionReceiptSchema = z.object({ version: z.literal("spawnfile.auth.credential-provisioning.receipt.v1"), world_bindings_digest: digest, }).strict(); -const bundleRequestSchema = z.object({ - archive_base64: z.string().max(5_592_408).refine((value) => { - if (value.length % 4 !== 0) return false; - const bytes = Buffer.from(value, "base64"); - return bytes.byteLength <= 4_194_304 && bytes.toString("base64") === value; - }), - archive_digest: digest, - archive_entries: z.array(z.string().min(1).max(256)).min(1).max(32), - artifact_digest: digest, - build_policy_digest: digest, - bundle_digest: digest, - entrypoint: z.string().min(1).max(256), - idempotency_key: z.string().regex(/^idem_[a-z0-9]{16,64}$/u), - launcher_digest: digest, - network_alias: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/u), - platform: z.object({ architecture: z.enum(["amd64", "arm64"]), - os: z.literal("linux") }).strict(), - platform_digest: digest, - selected_target: selectedTargetIdentitySchema, - version: z.literal("spawnfile.target-local-container-bundle.prepare-request.v1"), -}).strict(); - -export type SpawnfileSelectedTarget = z.infer; -export type SpawnfileBundlePolicy = z.infer; -export type SpawnfileBundlePreparationReceipt = z.infer; export type SpawnfileCredentialProvisioningReceipt = z.infer; +export const parseSpawnfileCredentialProvisioningReceipt = ( + raw: unknown, +): SpawnfileCredentialProvisioningReceipt => Object.freeze(provisionReceiptSchema.parse(raw)); const parseJson = (stdout: string, label: string): unknown => { try { return JSON.parse(stdout.trim()) as unknown; } catch { throw new TypeError(`Spawnfile ${label} did not emit JSON`); } }; -const canonicalTrustedJson = (value: unknown): string => { - if (Array.isArray(value)) return `[${value.map(canonicalTrustedJson).join(",")}]`; - if (value !== null && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record).sort().map((key) => - `${JSON.stringify(key)}:${canonicalTrustedJson(record[key])}`).join(",")}}`; - } - return JSON.stringify(value); -}; const temporaryJson = async ( prefix: string, value: Value, @@ -103,14 +42,8 @@ const temporaryJson = async ( return await work(file); } finally { await rm(root, { force: true, recursive: true }); } }; -const configBytes = (value: Uint8Array): Uint8Array => { - if (value.byteLength < 1 || value.byteLength > 262_144) { - throw new TypeError("Spawnfile target config is invalid"); - } - return Uint8Array.from(value); -}; -export const runSpawnfileCompile = async (context: SpawnfileCliContext, input: Readonly<{ +export const runSpawnfileCompile = async (context: BootstrapSpawnfileCliContext, input: Readonly<{ compiled_output_directory: string; organization_path: string; signal?: AbortSignal; @@ -122,67 +55,11 @@ export const runSpawnfileCompile = async (context: SpawnfileCliContext, input: R ), "utf8")) as unknown; }; -export const runSpawnfileSelectTarget = async (context: SpawnfileCliContext, input: Readonly<{ - request: unknown; - signal?: AbortSignal; - target_config_stdin: Uint8Array; -}>): Promise => selectedTargetSchema.parse(await temporaryJson( - "simfile-spawnfile-select-", input.request, async (file) => { - const config = configBytes(input.target_config_stdin); - try { - const result = await runSpawnfileProcess(context, { args: ["target", "--config", "-", - "select_target", file], signal: input.signal, stdin: config }); - return parseJson(result.stdout, "target selection"); - } finally { config.fill(0); } - }, -)); - -export const runSpawnfileDeriveBundlePolicy = async ( - context: SpawnfileCliContext, - claims: unknown, - signal?: AbortSignal, -): Promise => policySchema.parse(await temporaryJson( - "simfile-spawnfile-policy-", claims, async (file) => parseJson(( - await runSpawnfileProcess(context, { args: ["target", "--config", "-", - "derive_container_bundle_policy", file], signal, - stdin: new TextEncoder().encode("{}") }) - ).stdout, "bundle policy"), -)); - -const bundleDigest = (domain: "request" | "receipt", value: unknown): string => - `sha256:${createHash("sha256") - .update(`spawnfile.target-local-container-bundle.${domain}.v1\0`) - .update(canonicalTrustedJson(value)).digest("hex")}`; - -export const runSpawnfilePrepareContainerBundle = async ( - context: SpawnfileCliContext, - input: Readonly<{ request: Readonly>; signal?: AbortSignal; - target_config_stdin: Uint8Array }>, -): Promise => { - const request = bundleRequestSchema.parse(input.request); - const receipt = bundleReceiptSchema.parse(await temporaryJson( - "simfile-spawnfile-bundle-", request, async (file) => { - const config = configBytes(input.target_config_stdin); - try { - return parseJson((await runSpawnfileProcess(context, { args: ["target", "--config", "-", - "prepare_container_bundle", file], signal: input.signal, stdin: config })).stdout, - "bundle preparation"); - } finally { config.fill(0); } - }, canonicalTrustedJson, - )); - const { receipt_digest: _receiptDigest, ...body } = receipt; - if (receipt.request_digest !== bundleDigest("request", request) - || receipt.receipt_digest !== bundleDigest("receipt", body)) { - throw new TypeError("Spawnfile bundle preparation correlation is invalid"); - } - return receipt; -}; - export const runSpawnfileProvisionCredentials = async ( - context: SpawnfileCliContext, + context: BootstrapSpawnfileCliContext, input: Readonly<{ env_file: string; request: unknown; resolved_grants_file: string; signal?: AbortSignal; world_bindings_file: string }>, -): Promise => provisionReceiptSchema.parse( +): Promise => parseSpawnfileCredentialProvisioningReceipt( await temporaryJson("simfile-spawnfile-auth-", input.request, async (file) => parseJson(( await runSpawnfileProcess(context, { args: ["auth", "provision", file, "--env-file", input.env_file, "--resolved-grants", input.resolved_grants_file, @@ -191,7 +68,7 @@ export const runSpawnfileProvisionCredentials = async ( ); export const runSpawnfileRevokeCredentialSource = async ( - context: SpawnfileCliContext, + context: BootstrapSpawnfileCliContext, input: Readonly<{ signal?: AbortSignal; source_handle: string }>, ): Promise => { const failures: unknown[] = []; diff --git a/src/spawnfile/cli.test.ts b/src/spawnfile/cli.test.ts index 9bb0160..afad472 100644 --- a/src/spawnfile/cli.test.ts +++ b/src/spawnfile/cli.test.ts @@ -68,12 +68,14 @@ process.stderr.write(input + " token=password\\n"); process.exitCode = 1;`); }), (error: Error) => !error.message.includes(secret) && !error.message.includes("password"), ); - const slow = await script(root, "setTimeout(() => {}, 10000);"); + const slow = await script(root, `process.stdout.write("token=private"); +process.stderr.write("${secret}"); setTimeout(() => {}, 10000);`); await assert.rejects( runSpawnfileComposedPreparation({ spawnfileBin: slow, timeoutMs: 20 }, { request, targetConfigStdin: secret, }), - /timed out/u, + (error: Error) => error.message.includes("timed out") + && !error.message.includes("token=private") && !error.message.includes(secret), ); const malformed = await script(root, "process.stdin.resume(); process.stdout.write('token=private');"); await assert.rejects( @@ -106,7 +108,7 @@ process.stdout.write(${JSON.stringify(`${JSON.stringify(receipt)}\n`)});`); containerName: "organization-unit", deploymentName: "organization-unit", descriptorDigest: `sha256:${"a".repeat(64)}`, - dockerContext: "gpu-4090", + dockerContext: "local-test-target", envFile: "/private/runtime.env", imageTag: "organization-unit:run-one", lifecycleInvocationId: invocation, @@ -124,7 +126,7 @@ process.stdout.write(${JSON.stringify(`${JSON.stringify(receipt)}\n`)});`); assert.deepEqual(JSON.parse(await readFile(capture, "utf8")), [ "up", "/project/Spawnfile", "--detach", "--name", "organization-unit", "--deployment", "organization-unit", "--out", "/compiled", - "--tag", "organization-unit:run-one", "--context", "gpu-4090", + "--tag", "organization-unit:run-one", "--context", "local-test-target", "--env-file", "/private/runtime.env", "--world-bindings", "/private/world-bindings.json", "--organization-handoff-run-id", "run-one", diff --git a/src/spawnfile/cli.ts b/src/spawnfile/cli.ts index 9eee16a..05ec87d 100644 --- a/src/spawnfile/cli.ts +++ b/src/spawnfile/cli.ts @@ -1,305 +1,17 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { - parseSpawnfileDownReceipt, - parseSpawnfileExportResult, - parseSpawnfileUpReceipt, - type SpawnfileDownReceipt, - type SpawnfileExportResult, - type SpawnfileUpReceipt -} from "./receipts.js"; -import { - parseSpawnfileComposedPreparationRequest, - verifySpawnfileComposedPreparationReceipt, - type SpawnfileComposedPreparationReceipt, - type SpawnfileComposedPreparationRequest -} from "./preparationReceipt.js"; -import { assertSecretFreeComposedJson } from "../compose/json.js"; -import { - runSpawnfileProcess, - type SpawnfileCliContext, -} from "./process.js"; - export type { SpawnfileCliContext } from "./process.js"; -const AUTH_PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; - -export const assertSpawnfileAuthProfileName = ( - value: string | undefined -): void => { - if (value !== undefined && !AUTH_PROFILE_NAME.test(value)) { - throw new Error("spawnfile auth profile name is not a safe identifier"); - } -}; - -/** - * Everything needed to shell the `spawnfile` CLI as a subprocess — the ONLY - * sanctioned way this package talks to Spawnfile (contracts.md's CLI rule: - * "simfile -> spawnfile only through documented CLI + versioned receipts"). - * `spawnfileBin` is a path to spawnfile's built `dist/cli/index.js` (or any - * equivalent entrypoint script); it is always invoked as `node - * ` rather than executed directly, so no `chmod +x`/shebang resolution - * is required of the caller. - */ -const execSpawnfileWithStdin = ( - context: SpawnfileCliContext, - args: readonly string[], - stdin: Uint8Array, - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string }> => runSpawnfileProcess(context, { - args, signal, stdin, -}); - -const parseComposedPreparationStdout = (stdout: string): unknown => { - try { - return JSON.parse(stdout.trim()) as unknown; - } catch { - throw new Error("spawnfile composed preparation did not emit valid JSON"); - } -}; - -export interface RunSpawnfileComposedPreparationInput { - request: SpawnfileComposedPreparationRequest; - /** Exact private target configuration bytes; transferred only to child stdin. */ - targetConfigStdin: string | Uint8Array; - signal?: AbortSignal; -} - -/** Invokes Spawnfile's sole high-level preparation operation. The request file - * is secret-free and temporary; private target configuration is stdin-only. */ -export const runSpawnfileComposedPreparation = async ( - context: SpawnfileCliContext, - input: RunSpawnfileComposedPreparationInput -): Promise => { - const request = parseSpawnfileComposedPreparationRequest(input.request); - const config = typeof input.targetConfigStdin === "string" - ? new TextEncoder().encode(input.targetConfigStdin) - : input.targetConfigStdin instanceof Uint8Array - ? Uint8Array.from(input.targetConfigStdin) - : new Uint8Array(); - if (config.byteLength < 1 || config.byteLength > 262_144) { - throw new Error("spawnfile target configuration stdin is invalid"); - } - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-spawnfile-prepare-")); - try { - const requestFile = path.join(root, "request.json"); - await writeFile(requestFile, `${JSON.stringify(request)}\n`, { mode: 0o600 }); - const { stdout } = await execSpawnfileWithStdin(context, [ - "target", "--config", "-", "prepare_composed_run", requestFile, - ], config, input.signal); - return verifySpawnfileComposedPreparationReceipt({ - receipt: parseComposedPreparationStdout(stdout), - request - }); - } finally { - config.fill(0); - await rm(root, { force: true, recursive: true }); - } -}; - -const execSpawnfile = async ( - context: SpawnfileCliContext, - args: readonly string[], - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string }> => runSpawnfileProcess(context, { args, signal }); - -const parseTrailingJson = (stdout: string): unknown => { - const trimmed = stdout.trim(); - try { - return JSON.parse(trimmed); - } catch (error) { - throw new Error( - `spawnfile CLI did not print valid JSON on stdout: ${(error as Error).message}\n\n${trimmed}` - ); - } -}; - -export interface RunSpawnfileUpInput { - orgPath: string; - containerName: string; - deploymentName: string; - compiledOutputDirectory: string; - /** Named host auth profile only; never a credential or persisted secret. */ - authProfile?: string; - descriptorDigest: string; - dockerContext: string; - envFile: string; - imageTag: string; - lifecycleInvocationId?: string; - networkAttachmentHandle: string; - organizationHandoffRunId: string; - selectedTargetReceiptDigest: string; - selectedTargetReceiptFile: string; - signal?: AbortSignal; - worldBindingsFile: string; -} - -/** Shells `spawnfile up --detach --name --deployment - * --out --json`, detached, and parses the `spawnfile.up-receipt.v1` - * (run_id, moltnet base url, per-agent engine disclosure). */ -export const runSpawnfileUp = async ( - context: SpawnfileCliContext, - input: RunSpawnfileUpInput -): Promise => { - assertSpawnfileAuthProfileName(input.authProfile); - const args = [ - "up", - input.orgPath, - "--detach", - "--name", - input.containerName, - "--deployment", - input.deploymentName, - "--out", - input.compiledOutputDirectory, - "--tag", - input.imageTag, - "--context", - input.dockerContext, - "--env-file", - input.envFile, - "--world-bindings", - input.worldBindingsFile, - "--organization-handoff-run-id", - input.organizationHandoffRunId, - "--descriptor-digest", - input.descriptorDigest, - "--selected-target-receipt", - input.selectedTargetReceiptFile, - "--selected-target-receipt-digest", - input.selectedTargetReceiptDigest, - "--network-attachment-handle", - input.networkAttachmentHandle, - "--json" - ]; - if (input.authProfile !== undefined) { - args.push("--auth-profile", input.authProfile); - } - if (input.lifecycleInvocationId !== undefined) { - assertLifecycleInvocation(input.lifecycleInvocationId); - args.push("--lifecycle-invocation", input.lifecycleInvocationId); - } - const { stdout } = await execSpawnfile(context, args, input.signal); - return parseSpawnfileUpReceipt(parseTrailingJson(stdout)); -}; - -export interface RunSpawnfileArtifactsExportInput { - orgPath: string; - deploymentName: string; - compiledOutputDirectory: string; - destinationDirectory: string; - lifecycleInvocationId?: string; - signal?: AbortSignal; -} - -const assertLifecycleInvocation = (value: string): void => { - if (!/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u.test(value)) { - throw new Error("spawnfile lifecycle invocation id is invalid"); - } -}; - -/** Shells `spawnfile artifacts export --deployment --compiled - * --out --json`, run BEFORE `spawnfile down` (Decision 21's - * export-before-teardown discipline). Lands `raw/{moltnet,mneme,daimon}/...` - * directly under `destinationDirectory` plus `spawnfile/export-index.json`. */ -export const runSpawnfileArtifactsExport = async ( - context: SpawnfileCliContext, - input: RunSpawnfileArtifactsExportInput -): Promise => { - if (input.lifecycleInvocationId !== undefined - && !/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u.test(input.lifecycleInvocationId)) { - throw new Error("spawnfile lifecycle invocation id is invalid"); - } - const args = [ - "artifacts", - "export", - input.orgPath, - "--deployment", - input.deploymentName, - "--compiled", - input.compiledOutputDirectory, - "--out", - input.destinationDirectory, - "--json" - ]; - if (input.lifecycleInvocationId !== undefined) { - args.push("--lifecycle-invocation", input.lifecycleInvocationId); - } - const { stdout } = await execSpawnfile(context, args, input.signal); - return parseSpawnfileExportResult(parseTrailingJson(stdout)); -}; - -export interface RunSpawnfileDownInput { - orgPath: string; - deploymentName: string; - compiledOutputDirectory: string; - lifecycleInvocationId?: string; - removeVolumes?: boolean; - signal?: AbortSignal; -} - -/** Shells `spawnfile down --deployment --compiled - * --json`, run AFTER artifacts export. Never passes `--force`: a run whose - * export failed should fail loudly here rather than silently discard - * artifacts (the export-before-teardown invariant, `src/deployment/AGENTS.md`). */ -export const runSpawnfileDown = async ( - context: SpawnfileCliContext, - input: RunSpawnfileDownInput -): Promise => { - if (input.lifecycleInvocationId !== undefined - && !/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u.test(input.lifecycleInvocationId)) { - throw new Error("spawnfile lifecycle invocation id is invalid"); - } - const args = [ - "down", - input.orgPath, - "--deployment", - input.deploymentName, - "--compiled", - input.compiledOutputDirectory, - "--json" - ]; - if (input.removeVolumes) args.push("--volumes"); - if (input.lifecycleInvocationId !== undefined) { - args.push("--lifecycle-invocation", input.lifecycleInvocationId); - } - const { stdout } = await execSpawnfile(context, args, input.signal); - return parseSpawnfileDownReceipt(parseTrailingJson(stdout)); -}; - -const targetCommand = /^(?:attach_organization|cleanup_run|create_world_service|detach_organization|export_evidence_volume|query_world_clock|query_world_readiness|revoke_secret_bindings|snapshot_public_artifact|start_world_service|stop_world_service|attest_topology|activate_topology)$/u; - -/** Invokes one documented public target operation using an operator-owned config path. */ -export const runSpawnfileTargetCommand = async ( - context: SpawnfileCliContext, - input: Readonly<{ - command: string; - request: unknown; - signal?: AbortSignal; - targetConfigStdin: string | Uint8Array; - }>, -): Promise => { - if (!targetCommand.test(input.command)) { - throw new Error("spawnfile target command input is invalid"); - } - assertSecretFreeComposedJson(input.request); - const config = typeof input.targetConfigStdin === "string" - ? new TextEncoder().encode(input.targetConfigStdin) - : Uint8Array.from(input.targetConfigStdin); - if (config.byteLength < 1 || config.byteLength > 262_144) { - throw new Error("spawnfile target configuration stdin is invalid"); - } - const root = await mkdtemp(path.join(os.tmpdir(), "simfile-spawnfile-target-")); - try { - const requestFile = path.join(root, "request.json"); - await writeFile(requestFile, `${JSON.stringify(input.request)}\n`, { mode: 0o600 }); - const { stdout } = await execSpawnfileWithStdin(context, [ - "target", "--config", "-", input.command, requestFile, - ], config, input.signal); - return parseTrailingJson(stdout); - } finally { - config.fill(0); - await rm(root, { force: true, recursive: true }); - } -}; +export { + runSpawnfileComposedPreparation, + type RunSpawnfileComposedPreparationInput, +} from "./composedPreparationCli.js"; +export { + runSpawnfileUp, + type RunSpawnfileUpInput, +} from "./organizationUpCli.js"; +export { + runSpawnfileArtifactsExport, + runSpawnfileDown, + type RunSpawnfileArtifactsExportInput, + type RunSpawnfileDownInput, +} from "./organizationEvidenceCli.js"; +export { runSpawnfileTargetCommand } from "./targetCommandCli.js"; +export { assertSpawnfileAuthProfileName } from "./spawnfileCliShared.js"; diff --git a/src/spawnfile/composedPreparationCli.ts b/src/spawnfile/composedPreparationCli.ts new file mode 100644 index 0000000..eb26d8a --- /dev/null +++ b/src/spawnfile/composedPreparationCli.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + parseSpawnfileComposedPreparationRequest, + verifySpawnfileComposedPreparationReceipt, + type SpawnfileComposedPreparationReceipt, + type SpawnfileComposedPreparationRequest, +} from "./preparationReceipt.js"; +import type { SpawnfileCliContext } from "./process.js"; +import { execSpawnfileWithStdin, parseSpawnfileJson } from "./spawnfileCliShared.js"; + +export interface RunSpawnfileComposedPreparationInput { + request: SpawnfileComposedPreparationRequest; + /** Exact private target configuration bytes; transferred only to child stdin. */ + targetConfigStdin: string | Uint8Array; + signal?: AbortSignal; +} + +/** Invokes Spawnfile's high-level preparation using config bytes only on stdin. */ +export const runSpawnfileComposedPreparation = async ( + context: SpawnfileCliContext, + input: RunSpawnfileComposedPreparationInput, +): Promise => { + const request = parseSpawnfileComposedPreparationRequest(input.request); + const config = typeof input.targetConfigStdin === "string" + ? new TextEncoder().encode(input.targetConfigStdin) + : input.targetConfigStdin instanceof Uint8Array + ? Uint8Array.from(input.targetConfigStdin) : new Uint8Array(); + if (config.byteLength < 1 || config.byteLength > 262_144) { + throw new Error("spawnfile target configuration stdin is invalid"); + } + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-spawnfile-prepare-")); + try { + const requestFile = path.join(root, "request.json"); + await writeFile(requestFile, `${JSON.stringify(request)}\n`, { mode: 0o600 }); + const { stdout } = await execSpawnfileWithStdin(context, [ + "target", "--config", "-", "prepare_composed_run", requestFile, + ], config, input.signal); + return verifySpawnfileComposedPreparationReceipt({ + receipt: parseSpawnfileJson(stdout, "composed preparation"), request, + }); + } finally { + config.fill(0); + await rm(root, { force: true, recursive: true }); + } +}; diff --git a/src/spawnfile/composedTargetProvider.test.ts b/src/spawnfile/composedTargetProvider.test.ts new file mode 100644 index 0000000..b261baf --- /dev/null +++ b/src/spawnfile/composedTargetProvider.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createBootstrapComposedPhaseJournal, + type ComposedJournalSession, +} from "../compose/index.js"; +import { digestComposedJson } from "../compose/json.js"; +import { currentTargetOperation } from "../compose/operationJournal.js"; +import { captureBootstrapLocalExecutableIdentity } from "./process.js"; +import { createCliComposedTargetProvider } from "./composedTargetProvider.js"; + +const sha = (character: string): `sha256:${string}` => `sha256:${character.repeat(64)}`; +const request = { + descriptor_digest: sha("a"), expected_revision: 7, + idempotency_key: "idem_aaaaaaaaaaaaaaaa", operation: "create_world_service", + run_id: "run-target-crash", selected_target: { + fingerprint: `sha256:${"b".repeat(32)}`, handle: "opaque_bbbbbbbbbbbbbbbb", + }, version: "spawnfile.target-resource.request.v1", +} as const; +const requestDigest = digestComposedJson("spawnfile.target-resource.request.v1", request); +const receipt = { operation: request.operation, + operation_handle: "opaque_cccccccccccccccc", request_digest: requestDigest } as const; + +const session = (root: string): ComposedJournalSession => { + const runRequest = { + descriptor_digest: sha("a"), mode: "live", + organization: { artifact_digest: sha("b"), source_digest: sha("c"), + world_bindings_digest: sha("d") }, + required_world_capabilities: ["simfile.world-decision-claim.v1"], + run_id: request.run_id, source_digest: sha("e"), + target: { auth_profile: "scripted-no-model-auth", selector: "local_test" }, + version: "simfile.composed-run-request.v1", + world: { artifact_manifest_digest: sha("f"), bundle_digest: sha("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1" }, + } as const; + let journal = createBootstrapComposedPhaseJournal(runRequest, { + command_mode: "lifecycle-replay-smoke", + paths: { compiled: path.join(root, "compiled"), env_file: path.join(root, "env"), + grants_file: path.join(root, "grants"), journal: path.join(root, "journal.json"), + organization_evidence: path.join(root, "org-evidence"), + organization_path: "/tmp/project/Spawnfile", + preflight_report: path.join(root, "preflight-report.json"), + prepared_plan: path.join(root, "plan"), + run: "/tmp/run", selected_target_file: path.join(root, "selected"), + simfile: "/tmp/project/Simfile", support_root: root, + world_bindings_file: path.join(root, "bindings"), + world_evidence: path.join(root, "world-evidence"), + world_evidence_archive: path.join(root, "world.tar") }, + project: { compile_fingerprint: "sf1:aaaaaaaaaaaa", descriptor_digest: sha("a"), + preflight_report_digest: sha("0"), seed: "seed", + simfile_source_digest: sha("e"), spawnfile_source_digest: sha("c") }, + provider: { base_image: "node:22-bookworm-slim", capability_contract_digest: sha("2"), + context: "local_test", docker_command: "docker", + process_environment: { NOOPOLIS_RUN_ID: request.run_id, + SPAWNFILE_HOME: path.join(root, "auth") }, + spawnfile_bin: "/tmp/install/spawnfile", spawnfile_cwd: "/tmp/project", + spawnfile_executable_sha256: sha("3"), spawnfile_package_version: "0.1.17" }, + run_id: request.run_id, version: "simfile.composed-bootstrap-capsule.v2", + }, "2026-08-16T00:00:00.000Z"); + return { path: path.join(root, "journal.json"), assertCurrent: async () => undefined, + current: () => journal, replace: async (expected, next) => { + assert.equal(expected.journal_digest, journal.journal_digest); journal = next; + } }; +}; + +test("target crash window returns lookup truth for explicit completion without reinvoking", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-target-crash-")); + try { + const calls = path.join(root, "calls"); + const executable = path.join(root, "spawnfile.mjs"); + await writeFile(executable, `import { appendFile, readFile } from "node:fs/promises";\nconst argv = process.argv.slice(2); const command = argv[3]; const request = JSON.parse(await readFile(argv[4], "utf8")); await appendFile(process.env.CALLS, command + "\\n"); const receipt = ${JSON.stringify(receipt)}; if (command === "lookup_operation") process.stdout.write(JSON.stringify({ idempotency_key: request.idempotency_key, operation: request.operation, operation_handle: receipt.operation_handle, receipt, request_digest: receipt.request_digest, status: "completed", version: "spawnfile.target-resource.operation-lookup.v1" })); else process.stdout.write(JSON.stringify(receipt));\n`, { mode: 0o700 }); + const journalSession = session(root); + const context = { bootstrapLocalExecutableIdentity: + await captureBootstrapLocalExecutableIdentity(executable), + env: { ...process.env, CALLS: calls }, spawnfileBin: executable }; + const provider = await createCliComposedTargetProvider({ base_image: "node:22-bookworm-slim", + context, docker_command: "docker", evidence_destination: path.join(root, "world.tar"), + local_context: "local_test", prepared_plan: path.join(root, "plan"), + resolved_resolution: { config_bytes: new TextEncoder().encode("{}"), identity: { + base_image: { config_digest: sha("4"), reference: "node:22-bookworm-slim" }, + context: "local_test", endpoint_transport: "unix", + platform: { architecture: "amd64", os: "linux" }, + prepared_evidence_helper: { digest: sha("5"), handle: "opaque_dddddddddddddddd", + version: "spawnfile.target-evidence-export-helper.prepared.v1" }, + target_config_digest: sha("6"), version: "spawnfile.target-config-resolution.v1" } } }); + const signal = new AbortController().signal; + assert.deepEqual(await provider.request({ command: request.operation, + journal_session: journalSession, request, signal }), receipt); + assert.equal(currentTargetOperation(journalSession.current(), request.operation, request)?.state, + "intent_durable"); + const recovered = await provider.request({ command: request.operation, + journal_session: journalSession, request, signal }) as typeof receipt; + assert.deepEqual(recovered, receipt); + assert.equal(currentTargetOperation(journalSession.current(), request.operation, request)?.state, + "intent_durable"); + await provider.complete({ command: request.operation, journal_session: journalSession, + receipt: recovered, request }); + assert.equal(currentTargetOperation(journalSession.current(), request.operation, request)?.state, + "completed"); + await provider.complete({ command: request.operation, journal_session: journalSession, + receipt: recovered, request }); + await assert.rejects(provider.complete({ command: request.operation, + journal_session: journalSession, receipt: { ...recovered, + operation_handle: "opaque_eeeeeeeeeeeeeeee" }, request }), /receipt changed/u); + assert.deepEqual((await readFile(calls, "utf8")).trim().split("\n"), + [request.operation, "lookup_operation"]); + provider.close(); + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/spawnfile/composedTargetProvider.ts b/src/spawnfile/composedTargetProvider.ts new file mode 100644 index 0000000..c5d6ab0 --- /dev/null +++ b/src/spawnfile/composedTargetProvider.ts @@ -0,0 +1,199 @@ +import type { ComposedJournalSession } from "../compose/journalSession.js"; +import { + currentBootstrapOperation, + journalBootstrapOperationIntent, + journalBootstrapOperationObservation, +} from "../compose/bootstrapOperationJournal.js"; +import { canonicalComposedJson } from "../compose/json.js"; +import { currentTargetOperation, journalTargetOperationIntent, journalTargetOperationObservation } from "../compose/operationJournal.js"; +import { runSpawnfileComposedPreparation, runSpawnfileTargetCommand } from "./cli.js"; +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; +import { parseSpawnfileTargetConfigResolution, type SpawnfileTargetConfigResolution } from "./targetConfigResolution.js"; +import { SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS } from "./targetConfigPreview.js"; +import { parseTargetOperationLookup, type TargetOperationLookup } from "./targetOperationLookup.js"; + +/** + * Simfile's internal seam for the admitted released Spawnfile target contract. + * It deliberately carries opaque public requests and receipts only: Simfile + * does not reconstruct target configuration, choose a target, or accept an + * operator helper/environment ABI. + */ +export interface ComposedTargetProvider { + prepare(input: Readonly<{ + journal_session: ComposedJournalSession; + request: Readonly>; + signal: AbortSignal; + }>): Promise; + lookup(input: Readonly<{ + journal_session: ComposedJournalSession; + request: Readonly>; + signal: AbortSignal; + }>): Promise; + complete(input: Readonly<{ + command: string; + journal_session: ComposedJournalSession; + receipt: Readonly>; + request: Readonly>; + }>): Promise; + request(input: Readonly<{ + command: string; + journal_session: ComposedJournalSession; + request: Readonly>; + signal: AbortSignal; + }>): Promise; +} + +export interface CliComposedTargetProvider extends ComposedTargetProvider { + readonly resolution: SpawnfileTargetConfigResolution["identity"]; + close(): void; +} + +/** Builds the only admitted target adapter from Spawnfile's documented resolver. */ +export const createCliComposedTargetProvider = async (input: Readonly<{ + base_image: string; + context: BootstrapSpawnfileCliContext; + docker_command: string; + evidence_destination: string; + local_context: string; + prepared_plan: string; + expected_resolution?: SpawnfileTargetConfigResolution["identity"]; + resolved_resolution?: SpawnfileTargetConfigResolution; + signal?: AbortSignal; +}>): Promise => { + const args = ["target", "resolve_config", + "--context", input.local_context, "--evidence-destination", input.evidence_destination, + "--prepared-plan", input.prepared_plan, "--prepare-evidence-helper", + "--timeout-ms", String(SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS)]; + if (input.base_image !== "node:22-bookworm-slim") { + args.push("--base-image", input.base_image); + } + if (input.docker_command !== "docker") { + args.push("--docker-command", input.docker_command); + } + const resolved = input.resolved_resolution === undefined + ? await runSpawnfileProcess(input.context, { args, signal: input.signal }) : undefined; + let state = input.resolved_resolution ?? parseSpawnfileTargetConfigResolution( + JSON.parse(resolved!.stdout) as unknown, input.local_context, + ); + if (input.expected_resolution !== undefined + && canonicalComposedJson(state.identity) !== canonicalComposedJson(input.expected_resolution)) { + state.config_bytes.fill(0); + throw new TypeError("Spawnfile target configuration resolution changed"); + } + const assertOpen = (): Uint8Array => { + if (state.config_bytes.byteLength === 0) throw new TypeError("Spawnfile target provider is closed"); + return state.config_bytes; + }; + const mutation = new Set(["attach_organization", "cleanup_run", "create_world_service", + "detach_organization", "export_evidence_volume", "revoke_secret_bindings", "start_world_service", "stop_world_service"]); + const provider: CliComposedTargetProvider = { + resolution: state.identity, + async prepare({ journal_session, request, signal }) { + let current = journal_session.current(); + let operation = currentBootstrapOperation(current, "prepare_composed_run"); + if (operation?.state === "completed") return operation.receipt; + if (operation === undefined) { + const intent = journalBootstrapOperationIntent(current, "prepare_composed_run", request); + await journal_session.replace(current, intent); + current = journal_session.current(); + operation = currentBootstrapOperation(current, "prepare_composed_run")!; + } + try { + const receipt = await runSpawnfileComposedPreparation(input.context, { + request: request as never, signal, targetConfigStdin: assertOpen(), + }); + const completed = journalBootstrapOperationObservation( + journal_session.current(), operation.operation_id, "completed", receipt, + ); + await journal_session.replace(journal_session.current(), completed); + return receipt; + } catch (error) { + const latest = journal_session.current(); + const pending = currentBootstrapOperation(latest, "prepare_composed_run"); + if (pending !== undefined && pending.state !== "completed") { + await journal_session.replace(latest, journalBootstrapOperationObservation( + latest, pending.operation_id, "lookup_required", + )); + } + throw error; + } + }, + async request({ command, journal_session, request, signal }) { + if (!mutation.has(command)) { + return runSpawnfileTargetCommand(input.context, { command, request, signal, targetConfigStdin: assertOpen() }); + } + const current = journal_session.current(); + const prior = currentTargetOperation(current, command, request); + if (prior?.state === "completed") return prior.target_receipt; + let operation = prior; + if (operation !== undefined) { + const observed = await provider.lookup({ journal_session, request, signal }); + const latest = journal_session.current(); + if (observed.status === "completed") { + // The caller must verify the typed operation receipt before calling + // complete(); lookup alone never turns an observation into truth. + return observed.target_receipt; + } + const state = observed.status === "pending" ? "pending" : "not_applied"; + await journal_session.replace(latest, journalTargetOperationObservation( + latest, String(operation.operation_id), state, + )); + if (observed.status === "pending") { + throw new TypeError("Spawnfile target operation remains pending"); + } + } else { + const intent = journalTargetOperationIntent(current, command, request); + operation = intent.operations!.at(-1)!; + await journal_session.replace(current, intent); + } + try { + return await runSpawnfileTargetCommand(input.context, { command, request, signal, + targetConfigStdin: assertOpen() }); + } catch (error) { + const latest = journal_session.current(); + const pending = journalTargetOperationObservation(latest, + String(operation.operation_id), "lookup_required"); + await journal_session.replace(latest, pending); + throw error; + } + }, + async complete({ command, journal_session, receipt, request }) { + const current = journal_session.current(); + const pending = currentTargetOperation(current, command, request); + if (pending?.state === "completed") { + if (canonicalComposedJson(pending.target_receipt) + !== canonicalComposedJson(receipt)) { + throw new TypeError("completed target mutation receipt changed"); + } + return; + } + if (pending === undefined) { + throw new TypeError("target mutation completion has no durable intent"); + } + const completed = journalTargetOperationObservation(current, String(pending.operation_id), "completed", receipt); + await journal_session.replace(current, completed); + }, + async lookup({ request, signal }) { + const lookupConfig = new TextEncoder().encode(JSON.stringify({ context: state.identity.context, + version: "spawnfile.target-lookup-config.v1" })); + try { + const raw = await runSpawnfileTargetCommand(input.context, { command: "lookup_operation", request, + signal, targetConfigStdin: lookupConfig }); + return parseTargetOperationLookup(raw, request); + } finally { lookupConfig.fill(0); } + }, + close() { state.config_bytes.fill(0); state = { ...state, config_bytes: new Uint8Array() }; }, + }; + return Object.freeze(provider); +}; + +/** Explicit fail-closed provider for routes that have not completed admission. */ +export const unavailableComposedTargetProvider = (): ComposedTargetProvider => { + const unavailable = (): never => { + throw new TypeError( + "Simfile requires a released consumer-neutral Spawnfile target provider; " + + "manual target configuration is not supported", + ); + }; + return Object.freeze({ prepare: unavailable, request: unavailable, lookup: unavailable, complete: unavailable }); +}; diff --git a/src/spawnfile/containerBundleCli.test.ts b/src/spawnfile/containerBundleCli.test.ts new file mode 100644 index 0000000..f33b72e --- /dev/null +++ b/src/spawnfile/containerBundleCli.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { spawnfileBundleRequestDigest } from "./containerBundleCli.js"; + +const digest = `sha256:${"1".repeat(64)}`; + +test("bundle request digest admits the schema-bounded world archive", () => { + const archive = Buffer.alloc(300_000).toString("base64"); + assert.match(spawnfileBundleRequestDigest({ + archive_base64: archive, + archive_digest: digest, + archive_entries: ["world.mjs"], + artifact_digest: digest, + build_policy_digest: digest, + bundle_digest: digest, + entrypoint: "world.mjs", + idempotency_key: `idem_${"2".repeat(16)}`, + launcher_digest: digest, + network_alias: "world", + platform: { architecture: "arm64", os: "linux" }, + platform_digest: digest, + selected_target: { + fingerprint: `sha256:${"3".repeat(32)}`, + handle: `opaque_${"4".repeat(16)}`, + }, + version: "spawnfile.target-local-container-bundle.prepare-request.v1", + }), /^sha256:[a-f0-9]{64}$/u); +}); diff --git a/src/spawnfile/containerBundleCli.ts b/src/spawnfile/containerBundleCli.ts new file mode 100644 index 0000000..6f7c5fd --- /dev/null +++ b/src/spawnfile/containerBundleCli.ts @@ -0,0 +1,149 @@ +import { Buffer } from "node:buffer"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { z } from "zod"; + +import { canonicalComposedJson, digestComposedJson } from "../compose/json.js"; +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const selected = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), handle, +}).strict(); +const platform = z.object({ architecture: z.enum(["amd64", "arm64"]), + os: z.literal("linux") }).strict(); +const requestSchema = z.object({ + archive_base64: z.string().max(5_592_408).refine((value) => { + const bytes = Buffer.from(value, "base64"); + return value.length % 4 === 0 && bytes.byteLength <= 4_194_304 + && bytes.toString("base64") === value; + }), + archive_digest: digest, + archive_entries: z.array(z.string().min(1).max(256)).min(1).max(32), + artifact_digest: digest, + build_policy_digest: digest, + bundle_digest: digest, + entrypoint: z.string().min(1).max(256), + idempotency_key: z.string().regex(/^idem_[a-z0-9]{16,64}$/u), + launcher_digest: digest, + network_alias: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/u), + platform, + platform_digest: digest, + selected_target: selected, + version: z.literal("spawnfile.target-local-container-bundle.prepare-request.v1"), +}).strict(); +const receiptSchema = z.object({ + archive_digest: digest, artifact_digest: digest, build_policy_digest: digest, + bundle_digest: digest, launcher_digest: digest, mapping_handle: handle, + network_alias: z.string(), operation_handle: handle, platform, + platform_digest: digest, receipt_digest: digest, request_digest: digest, + selected_target: selected, + version: z.literal("spawnfile.target-local-container-bundle.prepare-receipt.v1"), +}).strict(); +const lookupSchema = z.discriminatedUnion("status", [ + z.object({ idempotency_key: z.string(), request_digest: digest, + status: z.literal("not_applied"), + version: z.literal("spawnfile.target-local-container-bundle.lookup.v1") }).strict(), + z.object({ idempotency_key: z.string(), operation_handle: handle, + request_digest: digest, status: z.literal("pending"), + version: z.literal("spawnfile.target-local-container-bundle.lookup.v1") }).strict(), + z.object({ idempotency_key: z.string(), operation_handle: handle, + receipt: receiptSchema, request_digest: digest, status: z.literal("completed"), + version: z.literal("spawnfile.target-local-container-bundle.lookup.v1") }).strict(), +]); +const policySchema = z.object({ build_policy_digest: digest, platform_digest: digest, + version: z.literal("spawnfile.target-local-container-bundle-policy.v1") }).strict(); + +export type SpawnfileBundleRequest = z.infer; +export type SpawnfileBundleReceipt = z.infer; +export type SpawnfileBundleLookup = z.infer; +export const parseSpawnfileBundleReceipt = (raw: unknown): SpawnfileBundleReceipt => { + const receipt = receiptSchema.parse(raw); + const { receipt_digest: _receipt, ...body } = receipt; + if (receipt.receipt_digest !== digestComposedJson( + "spawnfile.target-local-container-bundle.receipt.v1", body, + )) throw new TypeError("Spawnfile container bundle receipt digest is invalid"); + return Object.freeze(receipt); +}; + +const temporary = async (prefix: string, value: unknown, + work: (file: string) => Promise): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + try { + const file = path.join(root, "request.json"); + await writeFile(file, canonicalComposedJson(value), { mode: 0o600 }); + return await work(file); + } finally { await rm(root, { force: true, recursive: true }); } +}; +const parseJson = (value: string): unknown => { + try { return JSON.parse(value.trim()) as unknown; } + catch { throw new TypeError("Spawnfile container bundle command did not emit JSON"); } +}; +const configCopy = (value: Uint8Array): Uint8Array => { + if (value.byteLength < 1 || value.byteLength > 262_144) { + throw new TypeError("Spawnfile target configuration is invalid"); + } + return Uint8Array.from(value); +}; + +export const spawnfileBundleRequestDigest = (raw: unknown): `sha256:${string}` => + digestComposedJson("spawnfile.target-local-container-bundle.request.v1", + requestSchema.parse(raw)); + +export const runSpawnfileDeriveBundlePolicy = async ( + context: BootstrapSpawnfileCliContext, claims: unknown, signal?: AbortSignal, +) => policySchema.parse(parseJson(await temporary("simfile-bundle-policy-", claims, + async (file) => (await runSpawnfileProcess(context, { + args: ["target", "--config", "-", "derive_container_bundle_policy", file], + signal, stdin: new TextEncoder().encode("{}"), + })).stdout))); + +export const runSpawnfileContainerBundle = async (input: Readonly<{ + command: "prepare_container_bundle" | "recover_container_bundle"; + context: BootstrapSpawnfileCliContext; + request: unknown; + signal?: AbortSignal; + target_config: Uint8Array; +}>): Promise => { + const request = requestSchema.parse(input.request); + const config = configCopy(input.target_config); + try { + const raw = parseJson(await temporary("simfile-bundle-", request, + async (file) => (await runSpawnfileProcess(input.context, { + args: ["target", "--config", "-", input.command, file], + signal: input.signal, stdin: config, + })).stdout)); + const receipt = parseSpawnfileBundleReceipt(raw); + if (receipt.request_digest !== spawnfileBundleRequestDigest(request) + ) throw new TypeError("Spawnfile container bundle correlation is invalid"); + return Object.freeze(receipt); + } finally { config.fill(0); } +}; + +export const runSpawnfileContainerBundleLookup = async (input: Readonly<{ + context: BootstrapSpawnfileCliContext; + idempotency_key: string; + request_digest: string; + signal?: AbortSignal; + target_config: Uint8Array; +}>): Promise => { + const lookup = { idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + version: "spawnfile.target-local-container-bundle.lookup.v1" }; + const config = configCopy(input.target_config); + try { + const result = lookupSchema.parse(parseJson(await temporary( + "simfile-bundle-lookup-", lookup, async (file) => (await runSpawnfileProcess( + input.context, { args: ["target", "--config", "-", "lookup_container_bundle", file], + signal: input.signal, stdin: config }, + )).stdout, + ))); + if (result.request_digest !== input.request_digest) { + throw new TypeError("Spawnfile container bundle lookup correlation is invalid"); + } + return Object.freeze(result); + } finally { config.fill(0); } +}; diff --git a/src/spawnfile/evidenceHelperCli.ts b/src/spawnfile/evidenceHelperCli.ts new file mode 100644 index 0000000..74b3566 --- /dev/null +++ b/src/spawnfile/evidenceHelperCli.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; + +const receipt = z.object({ + digest: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + handle: z.string().regex(/^opaque_[a-f0-9]{64}$/u), + version: z.literal("spawnfile.target-evidence-export-helper.prepared.v1"), +}).strict(); + +export type SpawnfilePreparedEvidenceHelper = z.infer; + +/** Invokes the dedicated helper command whose executor supports tar stdin. */ +export const runSpawnfilePrepareEvidenceHelper = async (input: Readonly<{ + base_image: string; + context: BootstrapSpawnfileCliContext; + docker_command: string; + local_context: string; + signal?: AbortSignal; +}>): Promise => { + const args = ["helper", "prepare-evidence-export", "--context", input.local_context, + "--timeout-ms", "120000", "--json"]; + if (input.base_image !== "node:22-bookworm-slim") { + args.push("--base-image", input.base_image); + } + if (input.docker_command !== "docker") { + args.push("--docker-command", input.docker_command); + } + const result = await runSpawnfileProcess({ ...input.context, timeoutMs: 180_000 }, { + args, signal: input.signal, + }); + try { return Object.freeze(receipt.parse(JSON.parse(result.stdout) as unknown)); } + catch { throw new TypeError("Spawnfile evidence helper receipt is invalid"); } +}; diff --git a/src/spawnfile/executableIdentity.ts b/src/spawnfile/executableIdentity.ts new file mode 100644 index 0000000..d81a238 --- /dev/null +++ b/src/spawnfile/executableIdentity.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; + +export interface BootstrapLocalExecutableIdentity { + readonly path: string; + readonly sha256: `sha256:${string}`; +} + +const executableDigest = async (file: string): Promise<`sha256:${string}`> => + new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(file); + stream.on("data", (chunk: string | Buffer) => { hash.update(chunk); }); + stream.once("error", reject); + stream.once("end", () => resolve(`sha256:${hash.digest("hex")}`)); + }); + +export const captureBootstrapLocalExecutableIdentity = async ( + executablePath: string, +): Promise => { + if (!(await stat(executablePath)).isFile()) { + throw new TypeError("bootstrap executable must be a regular file"); + } + return Object.freeze({ path: executablePath, sha256: await executableDigest(executablePath) }); +}; + +export const assertBootstrapLocalExecutableIdentity = async ( + identity: BootstrapLocalExecutableIdentity, +): Promise => { + let current: BootstrapLocalExecutableIdentity; + try { current = await captureBootstrapLocalExecutableIdentity(identity.path); } + catch { throw new TypeError("bootstrap executable is unavailable or changed"); } + if (current.sha256 !== identity.sha256) { + throw new TypeError("bootstrap executable changed during composed bootstrap"); + } +}; diff --git a/src/spawnfile/journaledCredentialProvisioning.test.ts b/src/spawnfile/journaledCredentialProvisioning.test.ts new file mode 100644 index 0000000..cadc6d8 --- /dev/null +++ b/src/spawnfile/journaledCredentialProvisioning.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createBootstrapComposedPhaseJournal, + type ComposedJournalSession, +} from "../compose/index.js"; +import { + currentBootstrapOperation, + journalBootstrapOperationIntent, + journalBootstrapOperationObservation, +} from "../compose/bootstrapOperationJournal.js"; +import { captureBootstrapLocalExecutableIdentity } from "./process.js"; +import { provisionJournaledCredentials } from "./journaledCredentialProvisioning.js"; + +const sha = (character: string): `sha256:${string}` => `sha256:${character.repeat(64)}`; +const request = { + descriptor_digest: sha("a"), mode: "live", + organization: { artifact_digest: sha("b"), source_digest: sha("c"), + world_bindings_digest: sha("d") }, + required_world_capabilities: ["simfile.world-decision-claim.v1"], + run_id: "run-credential-crash", source_digest: sha("e"), + target: { auth_profile: "scripted-no-model-auth", selector: "local_test" }, + version: "simfile.composed-run-request.v1", + world: { artifact_manifest_digest: sha("f"), bundle_digest: sha("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1" }, +} as const; + +const capsule = (root: string) => ({ + command_mode: "lifecycle-replay-smoke", + paths: { compiled: path.join(root, "compiled"), env_file: path.join(root, "env"), + grants_file: path.join(root, "grants"), journal: path.join(root, "journal.json"), + organization_evidence: path.join(root, "org-evidence"), + organization_path: "/tmp/project/Spawnfile", + preflight_report: path.join(root, "preflight-report.json"), + prepared_plan: path.join(root, "plan"), + run: "/tmp/run", selected_target_file: path.join(root, "selected"), + simfile: "/tmp/project/Simfile", support_root: root, + world_bindings_file: path.join(root, "bindings"), + world_evidence: path.join(root, "world-evidence"), + world_evidence_archive: path.join(root, "world.tar") }, + project: { compile_fingerprint: "sf1:aaaaaaaaaaaa", descriptor_digest: sha("a"), + preflight_report_digest: sha("0"), seed: "seed", + simfile_source_digest: sha("e"), spawnfile_source_digest: sha("c") }, + provider: { base_image: "node:22-bookworm-slim", capability_contract_digest: sha("2"), + context: "local_test", docker_command: "docker", + process_environment: { NOOPOLIS_RUN_ID: request.run_id, + SPAWNFILE_HOME: path.join(root, "auth") }, + spawnfile_bin: "/tmp/install/spawnfile", spawnfile_cwd: "/tmp/project", + spawnfile_executable_sha256: sha("3"), spawnfile_package_version: "0.1.17" }, + run_id: request.run_id, version: "simfile.composed-bootstrap-capsule.v2", +} as const); + +const sessionWithPrerequisites = (root: string): ComposedJournalSession => { + let journal = createBootstrapComposedPhaseJournal( + request, capsule(root), "2026-08-16T00:00:00.000Z", + ); + for (const kind of ["resolve_target_config", "select_target", + "prepare_container_bundle"] as const) { + journal = journalBootstrapOperationIntent(journal, kind, { kind }); + const operation = currentBootstrapOperation(journal, kind)!; + journal = journalBootstrapOperationObservation( + journal, operation.operation_id, "completed", { kind }, + ); + } + return { + path: path.join(root, "journal.json"), + assertCurrent: async () => undefined, + current: () => journal, + replace: async (expected, next) => { + assert.equal(expected.journal_digest, journal.journal_digest); + journal = next; + }, + }; +}; + +test("credential crash becomes durable ambiguity and is never reinvoked", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-credential-crash-")); + try { + const calls = path.join(root, "calls"); + const executable = path.join(root, "spawnfile.mjs"); + await writeFile(executable, `import { appendFile } from "node:fs/promises";\nawait appendFile(process.env.CALLS, "called\\n");\nprocess.exitCode = 1;\n`, { mode: 0o700 }); + const envFile = path.join(root, "env"); + const grantsFile = path.join(root, "grants"); + const bindingsFile = path.join(root, "bindings"); + await Promise.all([envFile, grantsFile, bindingsFile].map((file) => writeFile(file, "{}"))); + const session = sessionWithPrerequisites(root); + const input = { context: { + bootstrapLocalExecutableIdentity: await captureBootstrapLocalExecutableIdentity(executable), + env: { ...process.env, CALLS: calls }, spawnfileBin: executable, + }, env_file: envFile, journal_session: session, + request: { run_id: request.run_id, version: "credential-request.v1" }, + resolved_grants_file: grantsFile, world_bindings_file: bindingsFile }; + await assert.rejects(provisionJournaledCredentials(input)); + assert.equal(currentBootstrapOperation( + session.current(), "provision_credentials", + )?.state, "ambiguous"); + await assert.rejects(provisionJournaledCredentials(input), /operator reconciliation/u); + assert.equal((await readFile(calls, "utf8")).trim().split("\n").length, 1); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/spawnfile/journaledCredentialProvisioning.ts b/src/spawnfile/journaledCredentialProvisioning.ts new file mode 100644 index 0000000..33f6392 --- /dev/null +++ b/src/spawnfile/journaledCredentialProvisioning.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { + currentBootstrapOperation, + journalBootstrapOperationIntent, + journalBootstrapOperationObservation, +} from "../compose/bootstrapOperationJournal.js"; +import type { ComposedJournalSession } from "../compose/journalSession.js"; +import { + parseSpawnfileCredentialProvisioningReceipt, + runSpawnfileProvisionCredentials, + type SpawnfileCredentialProvisioningReceipt, +} from "./bootstrapCli.js"; +import type { BootstrapSpawnfileCliContext } from "./process.js"; + +const sha256 = async (file: string): Promise<`sha256:${string}`> => + `sha256:${createHash("sha256").update(await readFile(file)).digest("hex")}`; + +const verifyFiles = async (receipt: SpawnfileCredentialProvisioningReceipt, + envFile: string, bindingsFile: string): Promise => { + if (receipt.env_file_digest !== await sha256(envFile) + || receipt.world_bindings_digest !== await sha256(bindingsFile)) { + throw new TypeError("Spawnfile credential output identity changed"); + } +}; + +export const provisionJournaledCredentials = async (input: Readonly<{ + context: BootstrapSpawnfileCliContext; + env_file: string; + journal_session: ComposedJournalSession; + request: Readonly>; + resolved_grants_file: string; + signal?: AbortSignal; + world_bindings_file: string; +}>): Promise => { + let operation = currentBootstrapOperation( + input.journal_session.current(), "provision_credentials", + ); + if (operation?.state === "completed") { + const receipt = parseSpawnfileCredentialProvisioningReceipt(operation.receipt); + await verifyFiles(receipt, input.env_file, input.world_bindings_file); + return receipt; + } + if (operation !== undefined) { + if (operation.state !== "ambiguous") { + await input.journal_session.replace(input.journal_session.current(), + journalBootstrapOperationObservation(input.journal_session.current(), + operation.operation_id, "ambiguous")); + } + throw new TypeError( + "Spawnfile credential provisioning outcome is ambiguous; retained resources require operator reconciliation", + ); + } + const current = input.journal_session.current(); + await input.journal_session.replace(current, journalBootstrapOperationIntent( + current, "provision_credentials", input.request, + )); + operation = currentBootstrapOperation( + input.journal_session.current(), "provision_credentials", + )!; + try { + const receipt = await runSpawnfileProvisionCredentials(input.context, { + env_file: input.env_file, + request: input.request, + resolved_grants_file: input.resolved_grants_file, + signal: input.signal, + world_bindings_file: input.world_bindings_file, + }); + await verifyFiles(receipt, input.env_file, input.world_bindings_file); + const latest = input.journal_session.current(); + await input.journal_session.replace(latest, journalBootstrapOperationObservation( + latest, operation.operation_id, "completed", receipt, + )); + return receipt; + } catch (error) { + const latest = input.journal_session.current(); + const pending = currentBootstrapOperation(latest, "provision_credentials"); + if (pending !== undefined && pending.state !== "completed") { + await input.journal_session.replace(latest, journalBootstrapOperationObservation( + latest, pending.operation_id, "ambiguous", + )); + } + throw error; + } +}; diff --git a/src/spawnfile/lifecycleLookup.test.ts b/src/spawnfile/lifecycleLookup.test.ts new file mode 100644 index 0000000..5129014 --- /dev/null +++ b/src/spawnfile/lifecycleLookup.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseSpawnfileLifecycleLookup, + resolveSpawnfileLifecycleOutcome, +} from "./lifecycleLookup.js"; + +const id = "lci_aaaaaaaaaaaaaaaa"; +const digest = `sha256:${"1".repeat(64)}`; +const input = { invocation_id: id, operation: "up" as const }; + +test("lifecycle lookup parses every exact typed state", () => { + assert.deepEqual(parseSpawnfileLifecycleLookup({ invocation_id: id, + status: "not_applied", version: "spawnfile.lifecycle-lookup.v1" }, input), { + invocation_id: id, status: "not_applied", + }); + assert.equal(parseSpawnfileLifecycleLookup({ invocation_digest: digest, + operation: "up", status: "pending", + version: "spawnfile.lifecycle-lookup.v1" }, input).status, "pending"); + assert.equal(parseSpawnfileLifecycleLookup({ invocation_digest: digest, + operation: "up", reason_code: "recovery_owner_died", status: "ambiguous", + version: "spawnfile.lifecycle-lookup.v1" }, input).status, "ambiguous"); + assert.deepEqual(parseSpawnfileLifecycleLookup({ invocation_digest: digest, + operation: "up", outcome_bytes: "{\"ok\":true}", status: "completed", + version: "spawnfile.lifecycle-lookup.v1" }, input), { + invocation_digest: digest, operation: "up", outcome: { ok: true }, status: "completed", + }); +}); + +test("lifecycle resolution invokes only a typed not-applied operation", async () => { + let invoked = 0; + const result = await resolveSpawnfileLifecycleOutcome({ ...input, + invoke: async () => { invoked += 1; return { fresh: true }; }, + lookup: async () => ({ invocation_id: id, status: "not_applied" }), + parse: (raw) => raw as { fresh: boolean }, + }); + assert.deepEqual(result, { fresh: true }); + assert.equal(invoked, 1); + const completed = await resolveSpawnfileLifecycleOutcome({ ...input, + invoke: async () => { invoked += 1; return { fresh: true }; }, + lookup: async () => ({ invocation_digest: digest as `sha256:${string}`, + operation: "up", outcome: { recovered: true }, status: "completed" }), + parse: (raw) => raw as { recovered: boolean }, + }); + assert.deepEqual(completed, { recovered: true }); + assert.equal(invoked, 1); +}); + +test("lifecycle resolution fails closed on pending and ambiguous states", async () => { + const base = { ...input, invoke: async () => ({}), parse: (raw: unknown) => raw }; + await assert.rejects(resolveSpawnfileLifecycleOutcome({ ...base, + lookup: async () => ({ invocation_digest: digest as `sha256:${string}`, + operation: "up", status: "pending" }) }), /remains pending/u); + await assert.rejects(resolveSpawnfileLifecycleOutcome({ ...base, + lookup: async () => ({ invocation_digest: digest as `sha256:${string}`, + operation: "up", reason_code: "reconciliation_ambiguous", + status: "ambiguous" }) }), /is ambiguous/u); +}); diff --git a/src/spawnfile/lifecycleLookup.ts b/src/spawnfile/lifecycleLookup.ts new file mode 100644 index 0000000..5375a9b --- /dev/null +++ b/src/spawnfile/lifecycleLookup.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { assertSecretFreeComposedJson } from "../compose/json.js"; +import { runSpawnfileProcess, type SpawnfileCliContext } from "./process.js"; + +const invocation = z.string().regex(/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u); +const operation = z.enum(["up", "artifacts_export", "down"]); +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const version = z.literal("spawnfile.lifecycle-lookup.v1"); +const lookup = z.discriminatedUnion("status", [ + z.object({ invocation_id: invocation, status: z.literal("not_applied"), version }).strict(), + z.object({ invocation_digest: digest, operation, status: z.literal("pending"), version }).strict(), + z.object({ invocation_digest: digest, operation, + reason_code: z.enum(["reconciliation_ambiguous", "recovery_owner_died"]), + status: z.literal("ambiguous"), version }).strict(), + z.object({ invocation_digest: digest, operation, + outcome_bytes: z.string().min(1).max(1_000_000), + status: z.literal("completed"), version }).strict(), +]); + +export type SpawnfileLifecycleOperation = z.infer; +export type SpawnfileLifecycleLookup = + | Readonly<{ invocation_id: string; status: "not_applied" }> + | Readonly<{ invocation_digest: `sha256:${string}`; + operation: SpawnfileLifecycleOperation; status: "pending" }> + | Readonly<{ invocation_digest: `sha256:${string}`; + operation: SpawnfileLifecycleOperation; + reason_code: "reconciliation_ambiguous" | "recovery_owner_died"; + status: "ambiguous" }> + | Readonly<{ invocation_digest: `sha256:${string}`; + operation: SpawnfileLifecycleOperation; outcome: unknown; status: "completed" }>; + +export const parseSpawnfileLifecycleLookup = (raw: unknown, input: Readonly<{ + invocation_id: string; + operation: SpawnfileLifecycleOperation; +}>): SpawnfileLifecycleLookup => { + assertSecretFreeComposedJson(raw); + const value = lookup.parse(raw); + if (value.status === "not_applied") { + if (value.invocation_id !== input.invocation_id) { + throw new TypeError("Spawnfile lifecycle lookup invocation changed"); + } + return Object.freeze({ invocation_id: value.invocation_id, status: value.status }); + } + if (value.operation !== input.operation) { + throw new TypeError("Spawnfile lifecycle lookup operation changed"); + } + if (value.status === "completed") { + let outcome: unknown; + try { outcome = JSON.parse(value.outcome_bytes) as unknown; } + catch { throw new TypeError("Spawnfile lifecycle outcome is invalid JSON"); } + assertSecretFreeComposedJson(outcome); + return Object.freeze({ invocation_digest: value.invocation_digest as `sha256:${string}`, + operation: value.operation, outcome, status: value.status }); + } + return Object.freeze({ invocation_digest: value.invocation_digest as `sha256:${string}`, + operation: value.operation, + ...(value.status === "ambiguous" ? { reason_code: value.reason_code } : {}), + status: value.status }) as SpawnfileLifecycleLookup; +}; + +export const runSpawnfileLifecycleLookup = async ( + context: SpawnfileCliContext, + input: Readonly<{ invocation_id: string; + operation: SpawnfileLifecycleOperation; signal?: AbortSignal }>, +): Promise => { + invocation.parse(input.invocation_id); + const result = await runSpawnfileProcess(context, { + args: ["lifecycle", "lookup", input.invocation_id], signal: input.signal, + }); + let raw: unknown; + try { raw = JSON.parse(result.stdout) as unknown; } + catch { throw new TypeError("Spawnfile lifecycle lookup did not emit JSON"); } + return parseSpawnfileLifecycleLookup(raw, input); +}; + +export const resolveSpawnfileLifecycleOutcome = async (input: Readonly<{ + invocation_id: string; + invoke: () => Promise; + lookup: () => Promise; + operation: SpawnfileLifecycleOperation; + parse: (raw: unknown) => Outcome; +}>): Promise => { + const observed = await input.lookup(); + if (observed.status === "completed") return input.parse(observed.outcome); + if (observed.status === "pending") { + throw new TypeError( + `Spawnfile ${input.operation} lifecycle ${input.invocation_id} remains pending`, + ); + } + if (observed.status === "ambiguous") { + throw new TypeError( + `Spawnfile ${input.operation} lifecycle ${input.invocation_id} is ambiguous (${observed.reason_code})`, + ); + } + return input.invoke(); +}; diff --git a/src/spawnfile/organizationAuthentication.test.ts b/src/spawnfile/organizationAuthentication.test.ts new file mode 100644 index 0000000..0d27e0f --- /dev/null +++ b/src/spawnfile/organizationAuthentication.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SCRIPTED_NO_MODEL_AUTH_PROFILE, + resolveSpawnfileOrganizationAuthentication, +} from "./organizationAuthentication.js"; + +test("all-scripted organizations carry only an inert correlation label", () => { + const authentication = resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: "must-not-propagate", + member_engines: { "agent:one": "scripted", "agent:two": "scripted" }, + }); + assert.deepEqual(authentication, { + correlation_auth_profile: SCRIPTED_NO_MODEL_AUTH_PROFILE, + kind: "scripted", + }); + assert.equal("model_engine_auth" in authentication, false); + assert.equal("spawnfile_up_auth_profile" in authentication, false); +}); + +test("Codex organizations retain explicit Codex import and Spawnfile profile", () => { + for (const member_engines of [ + { "agent:one": "codex" }, + { "agent:one": "scripted", "agent:two": "codex" }, + ] as readonly Readonly>[]) { + assert.deepEqual(resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: "developer-profile", + member_engines, + }), { + correlation_auth_profile: "developer-profile", + kind: "model", + model_engine_auth: { kind: "codex", profile: "developer-profile" }, + spawnfile_up_auth_profile: "developer-profile", + }); + assert.throws(() => resolveSpawnfileOrganizationAuthentication({ member_engines }), + /require SPAWNFILE_AUTH_PROFILE/u); + } +}); + +test("non-Codex model engines use the profile without fabricating Codex import", () => { + for (const engine of ["agy", "claude", "grok"]) { + assert.deepEqual(resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: "developer-profile", + member_engines: { "agent:one": engine }, + }), { + correlation_auth_profile: "developer-profile", + kind: "model", + spawnfile_up_auth_profile: "developer-profile", + }); + } +}); + +test("an empty engine disclosure never downgrades authentication", () => { + assert.throws(() => resolveSpawnfileOrganizationAuthentication({ + member_engines: {}, + }), /member engines are absent/u); +}); diff --git a/src/spawnfile/organizationAuthentication.ts b/src/spawnfile/organizationAuthentication.ts new file mode 100644 index 0000000..89d7152 --- /dev/null +++ b/src/spawnfile/organizationAuthentication.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const SCRIPTED_NO_MODEL_AUTH_PROFILE = "scripted-no-model-auth" as const; + +const authProfile = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); + +export type SpawnfileOrganizationAuthentication = + | Readonly<{ + correlation_auth_profile: typeof SCRIPTED_NO_MODEL_AUTH_PROFILE; + kind: "scripted"; + }> + | Readonly<{ + correlation_auth_profile: string; + kind: "model"; + model_engine_auth?: Readonly<{ kind: "codex"; profile: string }>; + spawnfile_up_auth_profile: string; + }>; + +/** Derives auth transport only from Spawnfile's compiled member-engine disclosure. */ +export const resolveSpawnfileOrganizationAuthentication = (input: Readonly<{ + configured_auth_profile?: string; + member_engines: Readonly>; +}>): SpawnfileOrganizationAuthentication => { + const engines = Object.values(input.member_engines); + if (engines.length < 1) { + throw new TypeError("Spawnfile member engines are absent"); + } + if (engines.every((engine) => engine === "scripted")) { + return Object.freeze({ + correlation_auth_profile: SCRIPTED_NO_MODEL_AUTH_PROFILE, + kind: "scripted" as const, + }); + } + if (input.configured_auth_profile === undefined) { + throw new TypeError( + "non-scripted composed organizations require SPAWNFILE_AUTH_PROFILE", + ); + } + let profile: string; + try { profile = authProfile.parse(input.configured_auth_profile); } + catch { + throw new TypeError( + "non-scripted composed organizations require SPAWNFILE_AUTH_PROFILE to be a safe identifier", + ); + } + return Object.freeze({ + correlation_auth_profile: profile, + kind: "model" as const, + ...(engines.includes("codex") ? { + model_engine_auth: Object.freeze({ kind: "codex" as const, profile }), + } : {}), + spawnfile_up_auth_profile: profile, + }); +}; diff --git a/src/spawnfile/organizationEvidenceCli.ts b/src/spawnfile/organizationEvidenceCli.ts new file mode 100644 index 0000000..d567ef0 --- /dev/null +++ b/src/spawnfile/organizationEvidenceCli.ts @@ -0,0 +1,60 @@ +import { + parseSpawnfileDownReceipt, + parseSpawnfileExportResult, + type SpawnfileDownReceipt, + type SpawnfileExportResult, +} from "./receipts.js"; +import type { SpawnfileCliContext } from "./process.js"; +import { + assertLifecycleInvocation, + execSpawnfile, + parseSpawnfileJson, +} from "./spawnfileCliShared.js"; + +export interface RunSpawnfileArtifactsExportInput { + orgPath: string; + deploymentName: string; + compiledOutputDirectory: string; + destinationDirectory: string; + lifecycleInvocationId?: string; + signal?: AbortSignal; +} + +export const runSpawnfileArtifactsExport = async ( + context: SpawnfileCliContext, + input: RunSpawnfileArtifactsExportInput, +): Promise => { + const args = ["artifacts", "export", input.orgPath, + "--deployment", input.deploymentName, "--compiled", input.compiledOutputDirectory, + "--out", input.destinationDirectory, "--json"]; + if (input.lifecycleInvocationId !== undefined) { + assertLifecycleInvocation(input.lifecycleInvocationId); + args.push("--lifecycle-invocation", input.lifecycleInvocationId); + } + const { stdout } = await execSpawnfile(context, args, input.signal); + return parseSpawnfileExportResult(parseSpawnfileJson(stdout)); +}; + +export interface RunSpawnfileDownInput { + orgPath: string; + deploymentName: string; + compiledOutputDirectory: string; + lifecycleInvocationId?: string; + removeVolumes?: boolean; + signal?: AbortSignal; +} + +export const runSpawnfileDown = async ( + context: SpawnfileCliContext, + input: RunSpawnfileDownInput, +): Promise => { + const args = ["down", input.orgPath, "--deployment", input.deploymentName, + "--compiled", input.compiledOutputDirectory, "--json"]; + if (input.removeVolumes) args.push("--volumes"); + if (input.lifecycleInvocationId !== undefined) { + assertLifecycleInvocation(input.lifecycleInvocationId); + args.push("--lifecycle-invocation", input.lifecycleInvocationId); + } + const { stdout } = await execSpawnfile(context, args, input.signal); + return parseSpawnfileDownReceipt(parseSpawnfileJson(stdout)); +}; diff --git a/src/spawnfile/organizationUpCli.ts b/src/spawnfile/organizationUpCli.ts new file mode 100644 index 0000000..b31b8b4 --- /dev/null +++ b/src/spawnfile/organizationUpCli.ts @@ -0,0 +1,50 @@ +import { parseSpawnfileUpReceipt, type SpawnfileUpReceipt } from "./receipts.js"; +import type { SpawnfileCliContext } from "./process.js"; +import { + assertLifecycleInvocation, + assertSpawnfileAuthProfileName, + execSpawnfile, + parseSpawnfileJson, +} from "./spawnfileCliShared.js"; + +export interface RunSpawnfileUpInput { + orgPath: string; + containerName: string; + deploymentName: string; + compiledOutputDirectory: string; + authProfile?: string; + descriptorDigest: string; + dockerContext: string; + envFile: string; + imageTag: string; + lifecycleInvocationId?: string; + networkAttachmentHandle: string; + organizationHandoffRunId: string; + selectedTargetReceiptDigest: string; + selectedTargetReceiptFile: string; + signal?: AbortSignal; + worldBindingsFile: string; +} + +export const runSpawnfileUp = async ( + context: SpawnfileCliContext, + input: RunSpawnfileUpInput, +): Promise => { + assertSpawnfileAuthProfileName(input.authProfile); + const args = ["up", input.orgPath, "--detach", "--name", input.containerName, + "--deployment", input.deploymentName, "--out", input.compiledOutputDirectory, + "--tag", input.imageTag, "--context", input.dockerContext, + "--env-file", input.envFile, "--world-bindings", input.worldBindingsFile, + "--organization-handoff-run-id", input.organizationHandoffRunId, + "--descriptor-digest", input.descriptorDigest, + "--selected-target-receipt", input.selectedTargetReceiptFile, + "--selected-target-receipt-digest", input.selectedTargetReceiptDigest, + "--network-attachment-handle", input.networkAttachmentHandle, "--json"]; + if (input.authProfile !== undefined) args.push("--auth-profile", input.authProfile); + if (input.lifecycleInvocationId !== undefined) { + assertLifecycleInvocation(input.lifecycleInvocationId); + args.push("--lifecycle-invocation", input.lifecycleInvocationId); + } + const { stdout } = await execSpawnfile(context, args, input.signal); + return parseSpawnfileUpReceipt(parseSpawnfileJson(stdout)); +}; diff --git a/src/spawnfile/preparationReceipt.test-helper.ts b/src/spawnfile/preparationReceipt.test-helper.ts index a199488..2e5ce5a 100644 --- a/src/spawnfile/preparationReceipt.test-helper.ts +++ b/src/spawnfile/preparationReceipt.test-helper.ts @@ -9,7 +9,7 @@ const sha = (value: string): `sha256:${string}` => export const composedPreparationRequestFixture = () => parseSpawnfileComposedPreparationRequest({ - auth_profile: "simfile-live", + auth_profile: "test-auth-profile", descriptor_digest: sha("a"), idempotency_key: "idem_prepare0000000000", organization: { artifact_digest: sha("b"), world_bindings_digest: sha("c") }, @@ -17,7 +17,7 @@ export const composedPreparationRequestFixture = () => secret_bindings: [{ name: "world_bearer", scope: "world", source_handle: `opaque_${"d".repeat(16)}`, }], - target_selector: "gpu-4090", + target_selector: "local-test-target", version: "spawnfile.composed-preparation.request.v1", world: { artifact_manifest_digest: sha("e"), bundle_digest: sha("f") }, }); diff --git a/src/spawnfile/process.test.ts b/src/spawnfile/process.test.ts index eba7e31..a009bdf 100644 --- a/src/spawnfile/process.test.ts +++ b/src/spawnfile/process.test.ts @@ -5,7 +5,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { runSpawnfileConfigProducer, runSpawnfileProcess } from "./process.js"; +import { + captureBootstrapLocalExecutableIdentity, + runSpawnfileProcess, +} from "./process.js"; const pause = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -55,7 +58,9 @@ fs.writeFileSync(path.join(process.env.PID_ROOT,role+".pid"),String(process.pid) process.on("SIGTERM",()=>{}); if(role==="root") spawn(process.execPath,[${JSON.stringify(script)},"grandchild"],{env:process.env,stdio:"ignore"}); if(role==="grandchild") spawn(process.execPath,[${JSON.stringify(script)},"great-grandchild"],{env:process.env,stdio:"ignore"}); -if(role==="root") process.stdout.write("token=must-not-escape"); +if(role==="root") { + const output=setInterval(()=>{if(fs.existsSync(path.join(process.env.PID_ROOT,"great-grandchild.pid"))){clearInterval(output);process.stdout.write("token=must-not-escape".repeat(8));}},5); +} setInterval(()=>{},1000); `, { mode: 0o700 }); await chmod(script, 0o700); @@ -90,16 +95,6 @@ test("abort terminates the Spawnfile process group through hostile descendants", }, { args: ["root"], signal })); }); -test("abort terminates the config producer process group through hostile descendants", async () => { - await assertAbortKillsTree((script, signal, root) => runSpawnfileConfigProducer({ - args: ["root"], - command: script, - env: { ...process.env, PID_ROOT: root }, - signal, - terminationGraceMs: 20, - })); -}); - test("normal exit and abort/exit races never affect an unrelated process", async () => { const root = await mkdtemp(path.join(tmpdir(), "simfile-process-race-")); const sentinel = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { @@ -129,3 +124,17 @@ test("normal exit and abort/exit races never affect an unrelated process", async await rm(root, { force: true, recursive: true }); } }); + +test("bootstrap-local identities reject a replaced Spawnfile executable", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-process-identity-")); + try { + const spawnfile = path.join(root, "spawnfile.mjs"); + await writeFile(spawnfile, "process.exit(0);\n", { mode: 0o700 }); + await chmod(spawnfile, 0o700); + const spawnfileIdentity = await captureBootstrapLocalExecutableIdentity(spawnfile); + await writeFile(spawnfile, "process.exit(1);\n", { mode: 0o700 }); + await assert.rejects(runSpawnfileProcess({ + bootstrapLocalExecutableIdentity: spawnfileIdentity, spawnfileBin: spawnfile, + }, { args: [] }), /changed during composed bootstrap/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/spawnfile/process.ts b/src/spawnfile/process.ts index bcad532..e03a38f 100644 --- a/src/spawnfile/process.ts +++ b/src/spawnfile/process.ts @@ -1,9 +1,28 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; + +import { + assertBootstrapLocalExecutableIdentity, + type BootstrapLocalExecutableIdentity, +} from "./executableIdentity.js"; +import { + processGroupIsAlive, + processTreeIdentity, + signalProcessTree, + type ProcessTreeIdentity, +} from "./processTree.js"; + +export { + assertBootstrapLocalExecutableIdentity, + captureBootstrapLocalExecutableIdentity, + type BootstrapLocalExecutableIdentity, +} from "./executableIdentity.js"; const MAX_BUFFER_BYTES = 64 * 1024 * 1024; +export const COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS = 600_000; export interface SpawnfileCliContext { spawnfileBin: string; + maxBufferBytes?: number; nodeBin?: string; cwd?: string; env?: NodeJS.ProcessEnv; @@ -11,6 +30,11 @@ export interface SpawnfileCliContext { timeoutMs?: number; } +/** Internal bootstrap context; never expose it through the public Spawnfile barrel. */ +export interface BootstrapSpawnfileCliContext extends SpawnfileCliContext { + readonly bootstrapLocalExecutableIdentity: BootstrapLocalExecutableIdentity; +} + const boundedMilliseconds = ( value: number | undefined, fallback: number, @@ -24,50 +48,6 @@ const boundedMilliseconds = ( return parsed; }; -type ProcessTreeIdentity = Readonly<{ pgid?: number; pid: number }>; - -const processTreeIdentity = (child: ChildProcess, isolatedGroup: boolean): ProcessTreeIdentity => { - const pid = child.pid; - if (!Number.isSafeInteger(pid) || pid === undefined || pid <= 1 || pid === process.pid) { - throw new Error("spawnfile CLI child process identity is invalid"); - } - return isolatedGroup ? { pgid: pid, pid } : { pid }; -}; - -const signalProcessTree = ( - child: ChildProcess, - identity: ProcessTreeIdentity, - signal: "SIGKILL" | "SIGTERM", -): void => { - if (identity.pgid !== undefined) { - if (!Number.isSafeInteger(identity.pgid) || identity.pgid <= 1 - || identity.pgid === process.pid || identity.pgid !== identity.pid) { - throw new Error("spawnfile CLI child process group is invalid"); - } - try { process.kill(-identity.pgid, signal); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } - return; - } - if (process.platform === "win32") { - const killer = spawn("taskkill", ["/PID", String(identity.pid), "/T", - ...(signal === "SIGKILL" ? ["/F"] : [])], { stdio: "ignore", windowsHide: true }); - killer.unref(); - return; - } - throw new Error("spawnfile CLI child process group is unavailable"); -}; - -const processGroupIsAlive = (identity: ProcessTreeIdentity): boolean => { - if (identity.pgid === undefined) return true; - try { - process.kill(-identity.pgid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code !== "ESRCH"; - } -}; - const runBoundedProcess = ( context: Omit, input: Readonly<{ @@ -100,12 +80,16 @@ const runBoundedProcess = ( let stderr = ""; let settled = false; let aborted = false; + let outputExceeded = false; let timedOut = false; let closed = false; let closeCode: number | null = null; let escalationComplete = false; let killTimer: NodeJS.Timeout | undefined; const timeoutMs = boundedMilliseconds(context.timeoutMs, 120_000, 600_000, "timeout"); + const maxBufferBytes = boundedMilliseconds( + context.maxBufferBytes, MAX_BUFFER_BYTES, MAX_BUFFER_BYTES, "output limit", + ); const graceMs = boundedMilliseconds( context.terminationGraceMs, 1_000, 30_000, "termination grace", ); @@ -121,29 +105,51 @@ const runBoundedProcess = ( if (error) reject(error); else if (aborted) reject(new Error("spawnfile CLI operation aborted")); else if (timedOut) reject(new Error("spawnfile CLI operation timed out")); - else if (code !== 0) reject(new Error( - `spawnfile CLI operation failed with exit code ${code ?? "unknown"}`, - )); + else if (outputExceeded) reject(new Error("spawnfile CLI output exceeded limit")); + else if (code !== 0) { + const failure = new Error( + `spawnfile CLI operation failed with exit code ${code ?? "unknown"}`, + ); + Object.defineProperty(failure, "stderr", { enumerable: false, value: stderr }); + reject(failure); + } else resolve({ stdout, stderr }); }; + const awaitQuiescence = (deadline: number, failure: string): void => { + const quiesced = identity.pgid === undefined ? closed : !processGroupIsAlive(identity); + if (quiesced) { + escalationComplete = true; + finish(undefined, closeCode); + return; + } + if (Date.now() >= deadline) { + escalationComplete = true; + finish(new Error(failure)); + return; + } + killTimer = setTimeout(() => awaitQuiescence(deadline, failure), 5); + }; const terminate = (reason: "abort" | "timeout"): void => { if (settled) return; aborted = reason === "abort"; timedOut = reason === "timeout"; - signalProcessTree(child, identity, "SIGTERM"); + try { signalProcessTree(child, identity, "SIGTERM"); } + catch { finish(new Error("spawnfile CLI termination failed")); return; } killTimer ??= setTimeout(() => { - signalProcessTree(child, identity, "SIGKILL"); - escalationComplete = true; - if (closed) finish(undefined, closeCode); + try { signalProcessTree(child, identity, "SIGKILL"); } + catch { finish(new Error("spawnfile CLI termination failed")); return; } + awaitQuiescence(Date.now() + graceMs, "spawnfile CLI termination did not quiesce"); }, graceMs); - killTimer.unref(); }; const abort = (): void => terminate("abort"); const collect = (kind: "stdout" | "stderr") => (chunk: Buffer): void => { const next = (kind === "stdout" ? stdout : stderr) + chunk.toString("utf8"); - if (Buffer.byteLength(next, "utf8") > MAX_BUFFER_BYTES) { - signalProcessTree(child, identity, "SIGKILL"); - finish(new Error("spawnfile CLI output exceeded limit")); + if (Buffer.byteLength(next, "utf8") > maxBufferBytes) { + if (outputExceeded) return; + outputExceeded = true; + try { signalProcessTree(child, identity, "SIGKILL"); } + catch { finish(new Error("spawnfile CLI output termination failed")); return; } + awaitQuiescence(Date.now() + graceMs, "spawnfile CLI output termination did not quiesce"); } else if (kind === "stdout") stdout = next; else stderr = next; }; @@ -156,7 +162,8 @@ const runBoundedProcess = ( child.once("close", (code) => { closed = true; closeCode = code; - if ((aborted || timedOut) && !escalationComplete && processGroupIsAlive(identity)) return; + if ((aborted || timedOut || outputExceeded) + && !escalationComplete && processGroupIsAlive(identity)) return; finish(undefined, code); }); child.stdin.once("error", () => undefined); @@ -164,49 +171,24 @@ const runBoundedProcess = ( }); export const runSpawnfileProcess = ( - context: SpawnfileCliContext, + context: SpawnfileCliContext | BootstrapSpawnfileCliContext, input: Readonly<{ args: readonly string[]; signal?: AbortSignal; stdin?: Uint8Array; }>, -): Promise<{ stdout: string; stderr: string }> => runBoundedProcess(context, { +): Promise<{ stdout: string; stderr: string }> => (async () => { + if ("bootstrapLocalExecutableIdentity" in context) { + if (context.bootstrapLocalExecutableIdentity.path !== context.spawnfileBin) { + throw new TypeError("bootstrap Spawnfile executable identity does not match its path"); + } + await assertBootstrapLocalExecutableIdentity(context.bootstrapLocalExecutableIdentity); + } + return runBoundedProcess(context, { ...input, // Node recognizes options such as --env-file even after a script path. // Keep every Spawnfile flag on the child CLI side of the option boundary. args: ["--", context.spawnfileBin, ...input.args], executable: context.nodeBin ?? process.execPath, -}); - -/** Recreates private target config in memory from one durable nonsecret argv contract. */ -export const runSpawnfileConfigProducer = async (input: Readonly<{ - args: readonly string[]; - command: string; - cwd?: string; - env?: NodeJS.ProcessEnv; - signal?: AbortSignal; - terminationGraceMs?: number; - timeoutMs?: number; -}>): Promise => { - if (input.command.length < 1 || input.command.length > 4_096 || input.command.includes("\0") - || input.args.length > 32 || input.args.some((value) => - value.length < 1 || value.length > 4_096 || value.includes("\0"))) { - throw new Error("spawnfile target config producer argv is invalid"); - } - const { stdout } = await runBoundedProcess({ - cwd: input.cwd, - env: input.env, - terminationGraceMs: input.terminationGraceMs, - timeoutMs: input.timeoutMs, - }, { - args: input.args, - executable: input.command, - signal: input.signal, }); - const bytes = new TextEncoder().encode(stdout); - if (bytes.byteLength < 1 || bytes.byteLength > 262_144) { - bytes.fill(0); - throw new Error("spawnfile target config producer output is invalid"); - } - return bytes; -}; +})(); diff --git a/src/spawnfile/processTree.ts b/src/spawnfile/processTree.ts new file mode 100644 index 0000000..5613b34 --- /dev/null +++ b/src/spawnfile/processTree.ts @@ -0,0 +1,45 @@ +import { spawn, type ChildProcess } from "node:child_process"; + +export type ProcessTreeIdentity = Readonly<{ pgid?: number; pid: number }>; + +export const processTreeIdentity = ( + child: ChildProcess, + isolatedGroup: boolean, +): ProcessTreeIdentity => { + const pid = child.pid; + if (!Number.isSafeInteger(pid) || pid === undefined || pid <= 1 || pid === process.pid) { + throw new Error("spawnfile CLI child process identity is invalid"); + } + return isolatedGroup ? { pgid: pid, pid } : { pid }; +}; + +export const signalProcessTree = ( + child: ChildProcess, + identity: ProcessTreeIdentity, + signal: "SIGKILL" | "SIGTERM", +): void => { + if (identity.pgid !== undefined) { + if (!Number.isSafeInteger(identity.pgid) || identity.pgid <= 1 + || identity.pgid === process.pid || identity.pgid !== identity.pid) { + throw new Error("spawnfile CLI child process group is invalid"); + } + try { process.kill(-identity.pgid, signal); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + return; + } + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/PID", String(identity.pid), "/T", + ...(signal === "SIGKILL" ? ["/F"] : [])], { stdio: "ignore", windowsHide: true }); + killer.unref(); + return; + } + throw new Error("spawnfile CLI child process group is unavailable"); +}; + +export const processGroupIsAlive = (identity: ProcessTreeIdentity): boolean => { + if (identity.pgid === undefined) return true; + try { process.kill(-identity.pgid, 0); return true; } + catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } +}; diff --git a/src/spawnfile/productionCleanupPorts.ts b/src/spawnfile/productionCleanupPorts.ts new file mode 100644 index 0000000..8487761 --- /dev/null +++ b/src/spawnfile/productionCleanupPorts.ts @@ -0,0 +1,93 @@ +import { createComposedCleanupOperationReceipt } from "../compose/cleanup.js"; +import type { ComposedExecution } from "../compose/execution.js"; +import { composedPhasePayload } from "../compose/phase.js"; +import type { ComposedRunPorts } from "../compose/run.js"; +import { parseComposedWorldServiceReceipt } from "../compose/startup-world.js"; +import { runSpawnfileDown } from "./cli.js"; +import { + resolveSpawnfileLifecycleOutcome, + runSpawnfileLifecycleLookup, +} from "./lifecycleLookup.js"; +import { + createProductionTargetDriver, + productionRecord as record, +} from "./productionTarget.js"; +import { parseSpawnfileDownReceipt } from "./receipts.js"; +import { parseTargetResourceReceipt, verifyTargetResourceReceipt } from "./targetReceipts.js"; + +type Driver = ReturnType; + +export const createProductionCleanupPort = ( + execution: ComposedExecution, + driver: Driver, +): ComposedRunPorts["cleanup"] => ({ + performCleanupOperation: async (input) => { + const journal = await driver.load(); + const resources = record(record( + composedPhasePayload(journal, "prepared").preparation, + ).resources); + const organization = record( + composedPhasePayload(journal, "organization_started").up_receipt, + ); + const attachment = parseTargetResourceReceipt(organization.target_attachment); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + if (input.operation === "detach_organization") { + const request = driver.mutation(journal, "detach_organization", input.idempotency_key, 10, { + data_network_handle: record(resources.data_network).result_handle, + organization_attachment_handle: attachment.result_handle, + }); + const receipt = verifyTargetResourceReceipt({ operation: "detach_organization", + raw: await driver.runTarget("detach_organization", request, input.signal), request, + resulting_revision: 11, run_id: journal.request.run_id }); + await driver.completeTarget("detach_organization", request, receipt); + } else if (input.operation === "down_organization") { + await driver.guard(); + const lifecycleId = execution.provider.lifecycle_invocations.down; + const down = await resolveSpawnfileLifecycleOutcome({ invocation_id: lifecycleId, + invoke: () => runSpawnfileDown(driver.cli, { + compiledOutputDirectory: execution.provider.compiled_output_directory, + deploymentName: execution.configuration.organization_expectation.deployment_name, + lifecycleInvocationId: lifecycleId, orgPath: execution.provider.organization_path, + removeVolumes: true, signal: input.signal, + }), lookup: () => runSpawnfileLifecycleLookup(driver.cli, { + invocation_id: lifecycleId, operation: "down", signal: input.signal, + }), operation: "down", parse: parseSpawnfileDownReceipt }); + if (down.deployment !== execution.configuration.organization_expectation.deployment_name + || down.errors.length > 0) { + throw new TypeError("organization down correlation is invalid"); + } + } else if (input.operation === "revoke_secret_bindings") { + const request = driver.mutation(journal, "revoke_secret_bindings", + input.idempotency_key, 11, { + secret_bindings_handle: record(resources.secret_bindings).result_handle, + }); + const receipt = verifyTargetResourceReceipt({ operation: "revoke_secret_bindings", + raw: await driver.runTarget("revoke_secret_bindings", request, input.signal), request, + resulting_revision: 12, run_id: journal.request.run_id }); + await driver.completeTarget("revoke_secret_bindings", request, receipt); + } else if (input.operation === "cleanup_target_resources") { + const request = driver.mutation(journal, "cleanup_run", input.idempotency_key, 12, { + cleanup_policy: "remove", + evidence_volume_handle: record(resources.evidence_volume).result_handle, + organization_attachment_handle: attachment.result_handle, + secret_bindings_handle: record(resources.secret_bindings).result_handle, + world_service_handle: service.service_handle, + }); + const receipt = verifyTargetResourceReceipt({ operation: "cleanup_run", + raw: await driver.runTarget("cleanup_run", request, input.signal), request, + resulting_revision: 13, run_id: journal.request.run_id }); + await driver.completeTarget("cleanup_run", request, receipt); + if (receipt.cleanup_state !== "removed") { + throw new TypeError("target cleanup is incomplete"); + } + } + const released = input.operation === "stop_world" ? [] : [...input.target_handles]; + return createComposedCleanupOperationReceipt({ operation: input.operation, + ownership_digest: input.ownership_digest, released_handles: released, + remaining_owned_handles: input.owned_handles.filter((owned) => !released.includes(owned)), + run_id: journal.request.run_id, state: "completed", + target_handles: [...input.target_handles] }); + }, +}); diff --git a/src/spawnfile/productionFinalizationPorts.ts b/src/spawnfile/productionFinalizationPorts.ts new file mode 100644 index 0000000..440853c --- /dev/null +++ b/src/spawnfile/productionFinalizationPorts.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import type { ComposedExecution } from "../compose/execution.js"; +import { + createComposedWorldEvidenceReceipt, + createComposedWorldPauseReceipt, +} from "../compose/finalize-world.js"; +import { composedPhasePayload } from "../compose/phase.js"; +import type { ComposedRunPorts } from "../compose/run.js"; +import { parseComposedWorldServiceReceipt } from "../compose/startup-world.js"; +import { runSpawnfileArtifactsExport } from "./cli.js"; +import { worldInventoryFromTargetExport } from "./evidenceInventory.js"; +import { + resolveSpawnfileLifecycleOutcome, + runSpawnfileLifecycleLookup, +} from "./lifecycleLookup.js"; +import { + createProductionTargetDriver, + productionRecord as record, +} from "./productionTarget.js"; +import { waitForProductionWorldTerminal } from "./productionTerminal.js"; +import { parseSpawnfileExportResult } from "./receipts.js"; +import { verifyTargetResourceReceipt } from "./targetReceipts.js"; +import { materializeWorldEvidenceArchive } from "./worldEvidenceArchive.js"; + +type Driver = ReturnType; +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); + +export const createProductionSupervisionPort = ( + execution: ComposedExecution, driver: Driver, +): ComposedRunPorts["supervision"] => ({ + waitForWorldTerminal: async ({ running, signal }) => { + const journal = await driver.load(); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + return waitForProductionWorldTerminal({ journal, provider: execution.provider, + run_target: driver.runTarget, running, selected_target: driver.selectedTarget, + service_handle: service.service_handle, signal }); + }, +}); + +export const createProductionWorldFinalizationPort = ( + execution: ComposedExecution, driver: Driver, +): ComposedRunPorts["world_finalization"] => ({ + pauseWorld: async ({ idempotency_key, service, signal, terminal }) => { + const journal = await driver.load(); + const request = driver.mutation(journal, "stop_world_service", idempotency_key, 7, + { world_service_handle: service.service_handle }); + const target = verifyTargetResourceReceipt({ operation: "stop_world_service", + raw: await driver.runTarget("stop_world_service", request, signal), request, + resulting_revision: 8, run_id: journal.request.run_id }); + await driver.completeTarget("stop_world_service", request, target); + return createComposedWorldPauseReceipt({ final_tick: terminal.terminal_tick, + run_id: journal.request.run_id, service_handle: service.service_handle, + target_operation: target, terminal_receipt_digest: terminal.receipt_digest }); + }, + exportWorldEvidence: async ({ idempotency_key, pause, signal }) => { + const journal = await driver.load(); + const prepared = record(composedPhasePayload(journal, "prepared").preparation); + const evidenceHandle = handle.parse( + record(record(prepared.resources).evidence_volume).result_handle, + ); + const request = driver.mutation(journal, "export_evidence_volume", idempotency_key, 9, + { evidence_volume_handle: evidenceHandle }); + const target = verifyTargetResourceReceipt({ operation: "export_evidence_volume", + raw: await driver.runTarget("export_evidence_volume", request, signal), request, + resulting_revision: 10, run_id: journal.request.run_id }); + await driver.completeTarget("export_evidence_volume", request, target); + if (target.export_state !== "exported") { + throw new TypeError("world evidence export is incomplete"); + } + const output = execution.provider.world_evidence_export; + if (output !== undefined) { + if (target.evidence_index === undefined) { + throw new TypeError("world evidence export index is absent"); + } + await materializeWorldEvidenceArchive({ archive_path: output.archive_path, + destination_directory: output.destination_directory, + evidence_index: target.evidence_index }); + } + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + return createComposedWorldEvidenceReceipt({ export_handle: handle.parse(target.result_handle), + inventory: worldInventoryFromTargetExport(target, evidenceHandle), + pause_receipt_digest: pause.receipt_digest, run_id: journal.request.run_id, + source_service_handle: service.service_handle, target_operation: target }); + }, +}); + +export const createProductionOrganizationFinalizationPort = ( + execution: ComposedExecution, driver: Driver, +): ComposedRunPorts["organization_finalization"] => ({ + exportOrganizationEvidence: async ({ deployment_name, lifecycle_invocation_id, signal }) => { + const provider = execution.provider; + if (lifecycle_invocation_id !== provider.lifecycle_invocations.export) { + throw new TypeError("organization export lifecycle invocation is not durable"); + } + await driver.guard(); + const invoke = () => runSpawnfileArtifactsExport(driver.cli, { + compiledOutputDirectory: provider.compiled_output_directory, + deploymentName: deployment_name, destinationDirectory: provider.evidence_destination_directory, + lifecycleInvocationId: lifecycle_invocation_id, orgPath: provider.organization_path, signal, + }); + return resolveSpawnfileLifecycleOutcome({ invocation_id: lifecycle_invocation_id, invoke, + lookup: () => runSpawnfileLifecycleLookup(driver.cli, { + invocation_id: lifecycle_invocation_id, operation: "artifacts_export", signal, + }), operation: "artifacts_export", parse: parseSpawnfileExportResult }); + }, +}); diff --git a/src/spawnfile/productionOrganizationPorts.test.ts b/src/spawnfile/productionOrganizationPorts.test.ts new file mode 100644 index 0000000..3b0e963 --- /dev/null +++ b/src/spawnfile/productionOrganizationPorts.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { ComposedExecution } from "../compose/execution.js"; +import { digestComposedJson } from "../compose/json.js"; +import { + lifecycleHandle, + lifecyclePreparation, + lifecycleRequest, + preparedLifecycleJournal, +} from "../compose/lifecycle.test-helper.js"; +import { SCRIPTED_NO_MODEL_AUTH_PROFILE } from "./organizationAuthentication.js"; +import { createProductionOrganizationPorts } from "./productionOrganizationPorts.js"; + +const execution = ( + memberEngines: Readonly>, +): ComposedExecution => ({ + configuration: { organization_expectation: { + deployment_name: "organization-unit", + member_engines: memberEngines, + selected_target_receipt_digest: `sha256:${"1".repeat(64)}`, + } }, + provider: { + compiled_output_directory: "/compiled", + lifecycle_invocations: { up: "lci_startorganization000000000000" }, + organization_container_name: "organization-unit", + organization_handoff: { + env_file: "/private/runtime.env", + selected_target_receipt_file: "/private/selected-target.json", + world_bindings_file: "/private/world-bindings.json", + }, + organization_image_tag: "organization-unit:run-one", + organization_path: "/project/Spawnfile", + }, +} as ComposedExecution); + +test("production organization up omits auth only for all-scripted members", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-organization-auth-")); + try { + const capture = path.join(root, "argv.json"); + const fakeSpawnfile = path.join(root, "fake-spawnfile.mjs"); + const upReceipt = { + deployment: { container_ids: ["organization-unit"], name: "organization-unit" }, + organization_handoff_handle: lifecycleHandle("5"), + readiness: { moltnet_base_url: "http://127.0.0.1:1", state: "running" }, + run_id: "run-lifecycle", + version: "spawnfile.up-receipt.v1", + }; + await writeFile(fakeSpawnfile, `import { writeFile } from "node:fs/promises"; +const args = process.argv.slice(2); +if (args[0] === "lifecycle" && args[1] === "lookup") { + process.stdout.write(JSON.stringify({ invocation_id: args[2], status: "not_applied", version: "spawnfile.lifecycle-lookup.v1" })); +} else { + await writeFile(process.env.CAPTURE_FILE, JSON.stringify(args)); + process.stdout.write(${JSON.stringify(`${JSON.stringify(upReceipt)}\n`)}); +} +`); + + const run = async ( + memberEngines: Readonly>, + profile: string, + ): Promise => { + const request = lifecycleRequest({ target: { + auth_profile: profile, + selector: "local-test-target", + } }); + const preparation = lifecyclePreparation(request); + const { version: _selectedVersion, ...selectedTarget } = preparation.selected_target; + const journal = preparedLifecycleJournal(request); + const driver = { + cli: { env: { ...process.env, CAPTURE_FILE: capture }, spawnfileBin: fakeSpawnfile }, + guard: async () => undefined, + load: async () => journal, + mutation: ( + _journal: unknown, + operation: string, + idempotencyKey: string, + expectedRevision: number, + extra: Readonly>, + ) => ({ + descriptor_digest: request.descriptor_digest, + expected_revision: expectedRevision, + idempotency_key: idempotencyKey, + operation, + run_id: request.run_id, + selected_target: selectedTarget, + version: "spawnfile.target-resource.request.v1", + ...extra, + }), + runTarget: async ( + _operation: string, + targetRequest: Readonly>, + ) => { + const body = { + cleanup_state: "not_requested" as const, + descriptor_digest: request.descriptor_digest, + export_state: "not_requested" as const, + labels: [], + operation: "attach_organization" as const, + operation_handle: lifecycleHandle("9"), + request_digest: digestComposedJson( + "spawnfile.target-resource.request.v1", + targetRequest, + ), + result_handle: lifecycleHandle("6"), + resulting_revision: 7, + run_id: request.run_id, + selected_target: selectedTarget, + version: "spawnfile.target-resource.receipt.v1" as const, + }; + return { ...body, receipt_digest: digestComposedJson( + "spawnfile.target-resource.receipt.v1", body, + ) }; + }, + }; + await createProductionOrganizationPorts(execution(memberEngines), driver as never) + .startOrganization({ + idempotency_key: `idem_${"a".repeat(16)}`, + run_id: request.run_id, + signal: new AbortController().signal, + world_readiness_digest: `sha256:${"2".repeat(64)}`, + }); + return JSON.parse(await readFile(capture, "utf8")) as string[]; + }; + + const scriptedArgv = await run({ + "agent:one": "scripted", + "agent:two": "scripted", + }, SCRIPTED_NO_MODEL_AUTH_PROFILE); + assert.equal(scriptedArgv.includes("--auth-profile"), false); + + for (const memberEngines of [ + { "agent:one": "pi" }, + { "agent:one": "scripted", "agent:two": "pi" }, + ] as readonly Readonly>[]) { + const authenticatedArgv = await run(memberEngines, "developer-profile"); + const authFlag = authenticatedArgv.indexOf("--auth-profile"); + assert.notEqual(authFlag, -1); + assert.equal(authenticatedArgv[authFlag + 1], "developer-profile"); + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/spawnfile/productionOrganizationPorts.ts b/src/spawnfile/productionOrganizationPorts.ts index 8a75cf3..23416de 100644 --- a/src/spawnfile/productionOrganizationPorts.ts +++ b/src/spawnfile/productionOrganizationPorts.ts @@ -4,6 +4,13 @@ import type { ComposedExecution } from "../compose/execution.js"; import { composedPhasePayload } from "../compose/phase.js"; import type { ComposedRunPorts } from "../compose/run.js"; import { runSpawnfileUp } from "./cli.js"; +import { parseSpawnfileUpReceipt } from "./receipts.js"; +import { + resolveSpawnfileLifecycleOutcome, + runSpawnfileLifecycleLookup, +} from "./lifecycleLookup.js"; +import { resolveSpawnfileOrganizationAuthentication } from + "./organizationAuthentication.js"; import { createProductionTargetDriver, productionRecord as record, @@ -19,16 +26,23 @@ export const createProductionOrganizationPorts = ( driver: ProductionTargetDriver, ): ComposedRunPorts["organization"] => { const provider = execution.provider; - const { cli, guard, load, mutation, runTarget } = driver; + const { cli, completeTarget, guard, load, mutation, runTarget } = driver; + const complete = completeTarget ?? (async (_command: string, _request: Readonly>, + _receipt: Readonly>): Promise => undefined); return { startOrganization: async ({ idempotency_key, signal }) => { const journal = await load(); const preparation = record(composedPhasePayload(journal, "prepared").preparation); const dataNetwork = record(record(preparation.resources).data_network); const networkAttachmentHandle = handle.parse(dataNetwork.result_handle); + const authentication = resolveSpawnfileOrganizationAuthentication({ + configured_auth_profile: journal.request.target.auth_profile, + member_engines: execution.configuration.organization_expectation.member_engines, + }); await guard(); - const up = await runSpawnfileUp(cli, { - authProfile: journal.request.target.auth_profile, + const upInput = { + ...(authentication.kind === "model" + ? { authProfile: authentication.spawnfile_up_auth_profile } : {}), compiledOutputDirectory: provider.compiled_output_directory, containerName: provider.organization_container_name, deploymentName: execution.configuration.organization_expectation.deployment_name, @@ -46,6 +60,16 @@ export const createProductionOrganizationPorts = ( provider.organization_handoff.selected_target_receipt_file, signal, worldBindingsFile: provider.organization_handoff.world_bindings_file, + } as const; + const lifecycleId = provider.lifecycle_invocations.up; + const up = await resolveSpawnfileLifecycleOutcome({ + invocation_id: lifecycleId, + invoke: () => runSpawnfileUp(cli, upInput), + lookup: () => runSpawnfileLifecycleLookup(cli, { + invocation_id: lifecycleId, operation: "up", signal, + }), + operation: "up", + parse: parseSpawnfileUpReceipt, }); const handoff = handle.parse(record(up).organization_handoff_handle); const request = mutation(journal, "attach_organization", idempotency_key, 6, { @@ -59,6 +83,7 @@ export const createProductionOrganizationPorts = ( resulting_revision: 7, run_id: journal.request.run_id, }); + await complete("attach_organization", request, attachment); return { ...up, target_attachment: attachment }; }, readOrganizationReadiness: async ({ up_receipt }) => up_receipt, diff --git a/src/spawnfile/productionPorts.test.ts b/src/spawnfile/productionPorts.test.ts new file mode 100644 index 0000000..dd0f34a --- /dev/null +++ b/src/spawnfile/productionPorts.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ComposedExecution } from "../compose/execution.js"; +import { lifecycleRequest } from "../compose/lifecycle.test-helper.js"; +import { createProductionComposedRunPorts } from "./productionPorts.js"; + +test("production ports reject lifecycle preparation without a released generic target provider", async () => { + const request = lifecycleRequest(); + const ports = createProductionComposedRunPorts({ + execution: { + configuration: { topology_expectation: { selected_target: { + fingerprint: `sha256:${"1".repeat(32)}`, handle: `opaque_${"2".repeat(16)}`, + } } }, + provider: { spawnfile_bin: "/spawnfile", spawnfile_cwd: "/", spawnfile_executable_sha256: `sha256:${"3".repeat(64)}` }, + secret_bindings: [], + } as unknown as ComposedExecution, + journal_session: { assertCurrent: async () => undefined } as never, + }); + await assert.rejects(ports.preparation.prepareComposedRun({ + idempotency_key: `idem_${"a".repeat(16)}`, + request, + signal: new AbortController().signal, + }), /released consumer-neutral Spawnfile target provider/u); +}); diff --git a/src/spawnfile/productionPorts.ts b/src/spawnfile/productionPorts.ts index a07a9ff..31e3bb9 100644 --- a/src/spawnfile/productionPorts.ts +++ b/src/spawnfile/productionPorts.ts @@ -1,372 +1,38 @@ -import { z } from "zod"; -import { createComposedTopologyActivationReceipt, createComposedTopologyAttestationReceipt, createComposedWorldTickReceipt } from "../compose/activation.js"; -import { createComposedCleanupOperationReceipt } from "../compose/cleanup.js"; import type { ComposedExecution } from "../compose/execution.js"; import type { ComposedJournalSession } from "../compose/journalSession.js"; -import { - createComposedWorldEvidenceReceipt, - createComposedWorldPauseReceipt, -} from "../compose/finalize-world.js"; -import { digestComposedJson } from "../compose/json.js"; -import { composedPhasePayload } from "../compose/phase.js"; import type { ComposedRunPorts } from "../compose/run.js"; +import type { ComposedTargetProvider } from "./composedTargetProvider.js"; +import { createProductionCleanupPort } from "./productionCleanupPorts.js"; import { - createComposedWorldResourceReceipt, - createComposedWorldServiceReceipt, - parseComposedWorldServiceReceipt, -} from "../compose/startup-world.js"; -import { - runSpawnfileArtifactsExport, - runSpawnfileComposedPreparation, - runSpawnfileDown, -} from "./cli.js"; -import { runSpawnfileConfigProducer } from "./process.js"; -import { worldInventoryFromTargetExport } from "./evidenceInventory.js"; + createProductionOrganizationFinalizationPort, + createProductionSupervisionPort, + createProductionWorldFinalizationPort, +} from "./productionFinalizationPorts.js"; import { createProductionOrganizationPorts } from "./productionOrganizationPorts.js"; -import { createProductionTargetDriver, productionRecord as record } from "./productionTarget.js"; -import { waitForProductionWorldTerminal } from "./productionTerminal.js"; +import { createProductionTargetDriver } from "./productionTarget.js"; +import { createProductionTopologyPort } from "./productionTopologyPorts.js"; import { - parseTargetResourceReceipt, - verifyTargetReadinessReceipt, - verifyTargetResourceReceipt, - verifyTargetWorldClockReceipt, -} from "./targetReceipts.js"; -import { materializeWorldEvidenceArchive } from "./worldEvidenceArchive.js"; -const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); -/** Creates the production composed ports using only built public Spawnfile commands. */ -export const createProductionComposedRunPorts = (input: Readonly<{ execution: ComposedExecution; - journal_session: ComposedJournalSession }>): ComposedRunPorts => { - const execution = input.execution; - const provider = execution.provider; + createProductionPreparationPort, + createProductionWorldPort, +} from "./productionWorldPorts.js"; + +/** Creates production ports using only pinned public Spawnfile commands. */ +export const createProductionComposedRunPorts = (input: Readonly<{ + execution: ComposedExecution; + journal_session: ComposedJournalSession; + target_provider?: ComposedTargetProvider; +}>): ComposedRunPorts => { const driver = createProductionTargetDriver(input); - const { cli, guard, load, mutation, runTarget, selectedTarget, topologyRequest } = driver; return { - preparation: { - prepareComposedRun: async ({ idempotency_key, request, signal }) => { - await guard(); - const config = await runSpawnfileConfigProducer({ - args: provider.target_config_producer.args, - command: provider.target_config_producer.command, - cwd: provider.spawnfile_cwd, - env: provider.process_environment === undefined - ? process.env : { ...process.env, ...provider.process_environment }, - signal, - }); - try { - await guard(); - return await runSpawnfileComposedPreparation(cli, { - request: { - auth_profile: request.target.auth_profile, - descriptor_digest: request.descriptor_digest, - idempotency_key, - organization: { - artifact_digest: request.organization.artifact_digest, - world_bindings_digest: request.organization.world_bindings_digest, - }, - run_id: request.run_id, - secret_bindings: execution.secret_bindings, - target_selector: request.target.selector, - version: "spawnfile.composed-preparation.request.v1", - world: { - artifact_manifest_digest: request.world.artifact_manifest_digest, - bundle_digest: request.world.bundle_digest, - }, - }, - signal, - targetConfigStdin: config, - }); - } finally { - config.fill(0); - } - }, - }, - world: { - createWorldResource: async ({ idempotency_key, signal }) => { - const journal = await load(); - const preparation = record(composedPhasePayload(journal, "prepared").preparation); - const resources = record(preparation.resources); - const request = mutation(journal, "create_world_service", idempotency_key, 4, { - data_network_handle: record(resources.data_network).result_handle, - evidence_mount_path: provider.evidence_mount_path, - evidence_volume_handle: record(resources.evidence_volume).result_handle, - secret_bindings_handle: record(resources.secret_bindings).result_handle, - world_artifact_handle: record(resources.world_artifact).result_handle, - }); - const targetOperation = verifyTargetResourceReceipt({ - operation: "create_world_service", - raw: await runTarget("create_world_service", request, signal), - request, resulting_revision: 5, run_id: journal.request.run_id, - }); - if (targetOperation.result_handle === null) throw new TypeError("world handle is missing"); - return createComposedWorldResourceReceipt({ - artifact_digest: journal.request.world.artifact_manifest_digest, - bundle_digest: journal.request.world.bundle_digest, - preparation_receipt_digest: z.string().parse(preparation.receipt_digest), - resource_handle: `opaque_${digestComposedJson( - "simfile.composed-world-resource-handle.v1", - { operation_handle: targetOperation.operation_handle, run_id: journal.request.run_id }, - ).slice(7, 39)}`, - run_id: journal.request.run_id, - target_operation: targetOperation, - }); - }, - startWorldPaused: async ({ idempotency_key, resource, signal }) => { - const journal = await load(); - const created = parseTargetResourceReceipt(resource.target_operation); - if (created.result_handle === null) throw new TypeError("world service handle is missing"); - const request = mutation(journal, "start_world_service", idempotency_key, 5, { - world_service_handle: created.result_handle, - }); - const targetOperation = verifyTargetResourceReceipt({ - operation: "start_world_service", - raw: await runTarget("start_world_service", request, signal), - request, resulting_revision: 6, run_id: journal.request.run_id, - }); - return createComposedWorldServiceReceipt({ - resource_handle: resource.resource_handle, - run_id: journal.request.run_id, - service_handle: handle.parse(targetOperation.result_handle), - target_operation: targetOperation, - }); - }, - readWorldReadiness: async ({ service, signal }) => { - const journal = await load(); - const expected = execution.configuration.readiness_expectation; - const request = { - descriptor_digest: journal.request.descriptor_digest, - endpoint: { internal_port: provider.world_readiness_port, path: "/v1/world/readiness" }, - expected: { - ...expected, - document_version: "simfile.world-sidecar-readiness.v1", - runtime_abi: journal.request.world.runtime_abi, - run_id: undefined, - }, - run_id: journal.request.run_id, - selected_target: selectedTarget, - version: "spawnfile.target-world-readiness.request.v1", - world_service_handle: service.service_handle, - }; - delete (request.expected as { run_id?: unknown }).run_id; - return verifyTargetReadinessReceipt({ - raw: await runTarget("query_world_readiness", request, signal), request, - }); - }, - }, - organization: createProductionOrganizationPorts(execution, driver), - topology: { - attestTopology: async ({ organization_phase_digest, request_digest, signal, world_phase_digest }) => { - const request = await topologyRequest(); - const targetTopology = await runTarget("attest_topology", request, signal); - return createComposedTopologyAttestationReceipt({ - organization_phase_digest, - request_digest, - run_id: request.run_id, - target_topology: targetTopology as never, - world_phase_digest, - }); - }, - activateTopology: async ({ attestation, signal }) => { - const request = await topologyRequest(); - const targetActivation = await runTarget("activate_topology", request, signal); - return createComposedTopologyActivationReceipt({ - attestation_receipt_digest: attestation.receipt_digest, - run_id: request.run_id, - target_activation: targetActivation as never, - }); - }, - readFirstTick: async ({ activation, signal }) => { - const journal = await load(); - const service = parseComposedWorldServiceReceipt( - composedPhasePayload(journal, "world_started_paused").receipt, - ); - const attestation = record( - composedPhasePayload(journal, "topology_verified").attestation, - ); - const targetTopology = record(attestation.target_topology); - const targetActivation = activation.target_activation; - const request = { - activation_digest: targetActivation.activation_digest, - activation_receipt_digest: targetActivation.receipt_digest, - descriptor_digest: journal.request.descriptor_digest, - endpoint: { internal_port: provider.world_readiness_port, path: "/v1/world/clock" }, - expected: { - document_version: "simfile.world-sidecar-clock.v1", - world_instance_id: execution.configuration.readiness_expectation.world_instance_id, - }, - run_id: journal.request.run_id, - selected_target: selectedTarget, - topology_receipt_digest: targetTopology.receipt_digest, - topology_request_digest: targetTopology.request_digest, - version: "spawnfile.target-world-clock.request.v1", - world_service_handle: service.service_handle, - } as const; - const observed = verifyTargetWorldClockReceipt({ - raw: await runTarget("query_world_clock", request, signal), request, - }); - return createComposedWorldTickReceipt({ - activation_receipt_digest: activation.receipt_digest, - clock: observed.clock, - run_id: journal.request.run_id, - world_phase_digest: journal.entries.find(({ phase }) => phase === "world_ready")!.payload_digest, - }); - }, - }, - supervision: { - waitForWorldTerminal: async ({ running, signal }) => { - const journal = await load(); - const service = parseComposedWorldServiceReceipt( - composedPhasePayload(journal, "world_started_paused").receipt, - ); - return waitForProductionWorldTerminal({ journal, provider, - run_target: runTarget, running, selected_target: selectedTarget, - service_handle: service.service_handle, signal }); - }, - }, - world_finalization: { - pauseWorld: async ({ idempotency_key, service, signal, terminal }) => { - const journal = await load(); - const request = mutation(journal, "stop_world_service", idempotency_key, 7, { - world_service_handle: service.service_handle, - }); - const targetOperation = verifyTargetResourceReceipt({ - operation: "stop_world_service", - raw: await runTarget("stop_world_service", request, signal), - request, resulting_revision: 8, run_id: journal.request.run_id, - }); - return createComposedWorldPauseReceipt({ - final_tick: terminal.terminal_tick, - run_id: journal.request.run_id, - service_handle: service.service_handle, - target_operation: targetOperation, - terminal_receipt_digest: terminal.receipt_digest, - }); - }, - exportWorldEvidence: async ({ idempotency_key, pause, signal }) => { - const journal = await load(); - const preparation = record(composedPhasePayload(journal, "prepared").preparation); - const evidenceHandle = handle.parse( - record(record(preparation.resources).evidence_volume).result_handle, - ); - // Revision 8 is consumed by Spawnfile's deterministic, target-owned - // evidence-helper admission immediately before the export mutation. - const request = mutation(journal, "export_evidence_volume", idempotency_key, 9, { - evidence_volume_handle: evidenceHandle, - }); - const targetOperation = verifyTargetResourceReceipt({ - operation: "export_evidence_volume", - raw: await runTarget("export_evidence_volume", request, signal), - request, resulting_revision: 10, run_id: journal.request.run_id, - }); - if (targetOperation.export_state !== "exported") { - throw new TypeError("world evidence export is incomplete"); - } - if (provider.world_evidence_export !== undefined) { - if (targetOperation.evidence_index === undefined) { - throw new TypeError("world evidence export index is absent"); - } - await materializeWorldEvidenceArchive({ - archive_path: provider.world_evidence_export.archive_path, - destination_directory: provider.world_evidence_export.destination_directory, - evidence_index: targetOperation.evidence_index, - }); - } - return createComposedWorldEvidenceReceipt({ - export_handle: handle.parse(targetOperation.result_handle), - inventory: worldInventoryFromTargetExport(targetOperation, evidenceHandle), - pause_receipt_digest: pause.receipt_digest, - run_id: journal.request.run_id, - source_service_handle: parseComposedWorldServiceReceipt( - composedPhasePayload(journal, "world_started_paused").receipt, - ).service_handle, - target_operation: targetOperation, - }); - }, - }, - organization_finalization: { - exportOrganizationEvidence: async ({ deployment_name, lifecycle_invocation_id, signal }) => { - if (lifecycle_invocation_id !== provider.lifecycle_invocations.export) { - throw new TypeError("organization export lifecycle invocation is not durable"); - } - await guard(); - return runSpawnfileArtifactsExport(cli, { - compiledOutputDirectory: provider.compiled_output_directory, - deploymentName: deployment_name, - destinationDirectory: provider.evidence_destination_directory, - lifecycleInvocationId: lifecycle_invocation_id, - orgPath: provider.organization_path, - signal, - }); - }, - }, - cleanup: { - performCleanupOperation: async (operationInput) => { - const journal = await load(); - const prepared = record(composedPhasePayload(journal, "prepared").preparation); - const resources = record(prepared.resources); - const organization = record(composedPhasePayload(journal, "organization_started").up_receipt); - const attachment = parseTargetResourceReceipt(organization.target_attachment); - const service = parseComposedWorldServiceReceipt( - composedPhasePayload(journal, "world_started_paused").receipt, - ); - if (operationInput.operation === "detach_organization") { - const request = mutation(journal, "detach_organization", operationInput.idempotency_key, 10, { - data_network_handle: record(resources.data_network).result_handle, - organization_attachment_handle: attachment.result_handle, - }); - verifyTargetResourceReceipt({ - operation: "detach_organization", raw: await runTarget("detach_organization", request, operationInput.signal), - request, resulting_revision: 11, run_id: journal.request.run_id, - }); - } else if (operationInput.operation === "down_organization") { - await guard(); - const down = await runSpawnfileDown(cli, { - compiledOutputDirectory: provider.compiled_output_directory, - deploymentName: execution.configuration.organization_expectation.deployment_name, - lifecycleInvocationId: provider.lifecycle_invocations.down, - orgPath: provider.organization_path, - removeVolumes: true, - signal: operationInput.signal, - }); - if (down.deployment !== execution.configuration.organization_expectation.deployment_name - || down.errors.length > 0) throw new TypeError("organization down correlation is invalid"); - } else if (operationInput.operation === "revoke_secret_bindings") { - const request = mutation(journal, "revoke_secret_bindings", operationInput.idempotency_key, 11, { - secret_bindings_handle: record(resources.secret_bindings).result_handle, - }); - verifyTargetResourceReceipt({ - operation: "revoke_secret_bindings", raw: await runTarget("revoke_secret_bindings", request, operationInput.signal), - request, resulting_revision: 12, run_id: journal.request.run_id, - }); - } else if (operationInput.operation === "cleanup_target_resources") { - const request = mutation(journal, "cleanup_run", operationInput.idempotency_key, 12, { - cleanup_policy: "remove", - evidence_volume_handle: record(resources.evidence_volume).result_handle, - organization_attachment_handle: attachment.result_handle, - secret_bindings_handle: record(resources.secret_bindings).result_handle, - world_service_handle: service.service_handle, - }); - const cleaned = verifyTargetResourceReceipt({ - operation: "cleanup_run", raw: await runTarget("cleanup_run", request, operationInput.signal), - request, resulting_revision: 13, run_id: journal.request.run_id, - }); - if (cleaned.cleanup_state !== "removed") { - throw new TypeError("target cleanup is incomplete"); - } - } - const released = operationInput.operation === "stop_world" - ? [] : [...operationInput.target_handles]; - return createComposedCleanupOperationReceipt({ - operation: operationInput.operation, - ownership_digest: operationInput.ownership_digest, - released_handles: released, - remaining_owned_handles: operationInput.owned_handles.filter( - (owned) => !released.includes(owned), - ), - run_id: journal.request.run_id, - state: "completed", - target_handles: [...operationInput.target_handles], - }); - }, - }, + cleanup: createProductionCleanupPort(input.execution, driver), + organization: createProductionOrganizationPorts(input.execution, driver), + organization_finalization: createProductionOrganizationFinalizationPort( + input.execution, driver, + ), + preparation: createProductionPreparationPort(input, driver), + supervision: createProductionSupervisionPort(input.execution, driver), + topology: createProductionTopologyPort(input.execution, driver), + world: createProductionWorldPort(input.execution, driver), + world_finalization: createProductionWorldFinalizationPort(input.execution, driver), }; }; diff --git a/src/spawnfile/productionTarget.ts b/src/spawnfile/productionTarget.ts index e8ed556..b294921 100644 --- a/src/spawnfile/productionTarget.ts +++ b/src/spawnfile/productionTarget.ts @@ -8,9 +8,12 @@ import { parseComposedWorldResourceReceipt, parseComposedWorldServiceReceipt, } from "../compose/startup-world.js"; -import { runSpawnfileTargetCommand } from "./cli.js"; -import { runSpawnfileConfigProducer } from "./process.js"; +import { + unavailableComposedTargetProvider, + type ComposedTargetProvider, +} from "./composedTargetProvider.js"; import { parseTargetResourceReceipt } from "./targetReceipts.js"; +import { COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS } from "./process.js"; const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); export const productionRecord = (value: unknown): Record => @@ -29,15 +32,25 @@ const operationTuple = (raw: unknown) => { export const createProductionTargetDriver = (input: Readonly<{ execution: ComposedExecution; journal_session: ComposedJournalSession; + target_provider?: ComposedTargetProvider; }>) => { const provider = input.execution.provider; const selectedTarget = input.execution.configuration.topology_expectation.selected_target; const environment = provider.process_environment === undefined ? process.env : { ...process.env, ...provider.process_environment }; - const cli = { cwd: provider.spawnfile_cwd, env: environment, - spawnfileBin: provider.spawnfile_bin }; + const cli = { + bootstrapLocalExecutableIdentity: { + path: provider.spawnfile_bin, + sha256: provider.spawnfile_executable_sha256 as `sha256:${string}`, + }, + cwd: provider.spawnfile_cwd, + env: environment, + spawnfileBin: provider.spawnfile_bin, + timeoutMs: COMPOSED_SPAWNFILE_OPERATION_TIMEOUT_MS, + }; const guard = () => input.journal_session.assertCurrent(); + const targetProvider = input.target_provider ?? unavailableComposedTargetProvider(); const load = async () => { await guard(); return input.journal_session.current(); @@ -48,21 +61,12 @@ export const createProductionTargetDriver = (input: Readonly<{ signal: AbortSignal, ): Promise => { await guard(); - const config = await runSpawnfileConfigProducer({ - args: provider.target_config_producer.args, - command: provider.target_config_producer.command, - cwd: provider.spawnfile_cwd, - env: environment, - signal, - }); - try { - await guard(); - return await runSpawnfileTargetCommand(cli, { - command, request, signal, targetConfigStdin: config, - }); - } finally { - config.fill(0); - } + return targetProvider.request({ command, journal_session: input.journal_session, request, signal }); + }; + const completeTarget = async (command: string, request: Readonly>, + receipt: Readonly>): Promise => { + await input.journal_session.assertCurrent(); + await targetProvider.complete({ command, journal_session: input.journal_session, receipt, request }); }; const mutation = ( journal: ComposedPhaseJournal, @@ -111,5 +115,5 @@ export const createProductionTargetDriver = (input: Readonly<{ }, } as const; }; - return Object.freeze({ cli, guard, load, mutation, runTarget, selectedTarget, topologyRequest }); + return Object.freeze({ cli, completeTarget, guard, load, mutation, runTarget, selectedTarget, targetProvider, topologyRequest }); }; diff --git a/src/spawnfile/productionTerminal.test.ts b/src/spawnfile/productionTerminal.test.ts new file mode 100644 index 0000000..267d035 --- /dev/null +++ b/src/spawnfile/productionTerminal.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import test from "node:test"; + +import { digestComposedJson } from "../compose/json.js"; +import { + lifecycleDigest, + tickOneLifecycleJournal, +} from "../compose/lifecycle.test-helper.js"; +import { + createComposedRunningReceipt, + parseComposedWorldTerminalReceipt, +} from "../compose/supervision.js"; +import { waitForProductionWorldTerminal } from "./productionTerminal.js"; + +const selectedTarget = { + fingerprint: `sha256:${"1".repeat(32)}` as const, + handle: "opaque_1111111111111111" as const, +}; + +const fixture = () => { + const journal = tickOneLifecycleJournal(); + const running = createComposedRunningReceipt({ + activation_receipt_digest: lifecycleDigest("8"), + first_tick_receipt_digest: lifecycleDigest("9"), + run_id: journal.request.run_id, + }); + return { journal, running }; +}; + +const snapshot = ( + request: Readonly>, + runId: string, +): Readonly> => { + const content = Buffer.from(JSON.stringify({ + outcome_digest: lifecycleDigest("0"), + reason: "completed", + run_id: runId, + terminal_tick: 4, + version: "simfile.composed-world-terminal-signal.v1", + })); + return { + artifact_id: "world_terminal", + content_base64: content.toString("base64"), + content_digest: `sha256:${createHash("sha256").update(content).digest("hex")}`, + media_type: "application/json", + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + run_id: runId, + size_bytes: content.byteLength, + version: "spawnfile.target-public-artifact-snapshot.v1", + }; +}; + +const run = ( + runTarget: ( + command: string, + request: Readonly>, + signal: AbortSignal, + ) => Promise, + signal: AbortSignal = new AbortController().signal, +) => { + const value = fixture(); + return waitForProductionWorldTerminal({ + journal: value.journal, + poll_interval_ms: 1, + provider: { + terminal_artifact: { + id: "world_terminal", + max_bytes: 4_096, + path: "/tmp/spawnfile-public/composed-terminal.json", + }, + }, + run_target: runTarget, + running: value.running, + selected_target: selectedTarget, + service_handle: "opaque_2222222222222222", + signal, + }); +}; + +test("production terminal polling retries only the typed not-present condition", async () => { + let calls = 0; + const raw = await run(async (_command, request) => { + calls += 1; + if (calls === 1) return { + artifact_id: "world_terminal", + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + run_id: "run-lifecycle", + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + }; + return snapshot(request, "run-lifecycle"); + }); + const receipt = parseComposedWorldTerminalReceipt(raw); + assert.equal(receipt.terminal_tick, 4); + assert.equal(calls, 2); +}); + +test("production terminal polling rejects uncorrelated not-present receipts", async () => { + let calls = 0; + await assert.rejects(run(async (_command, request) => { + calls += 1; + return { + artifact_id: "world_terminal", + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + run_id: "other-run", + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + }; + }, AbortSignal.timeout(50))); + assert.equal(calls, 1); +}); + +test("production terminal polling fails immediately on permanent target errors", async () => { + const failure = new Error("permanent target failure"); + let calls = 0; + await assert.rejects(run(async () => { + calls += 1; + throw failure; + }, AbortSignal.timeout(50)), (error) => error === failure); + assert.equal(calls, 1); +}); + +test("production terminal polling fails immediately on malformed target receipts", async () => { + let calls = 0; + await assert.rejects(run(async () => { + calls += 1; + return {}; + }, AbortSignal.timeout(50))); + assert.equal(calls, 1); +}); + +test("production terminal abort clears its pending poll timer", async () => { + const controller = new AbortController(); + const reason = new Error("stop terminal polling"); + let calls = 0; + const pending = run(async (_command, request) => { + calls += 1; + queueMicrotask(() => controller.abort(reason)); + return { + artifact_id: "world_terminal", + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + run_id: "run-lifecycle", + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + }; + }, controller.signal); + await assert.rejects(pending, (error) => error === reason); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(calls, 1); +}); diff --git a/src/spawnfile/productionTerminal.ts b/src/spawnfile/productionTerminal.ts index 634780c..5de7f22 100644 --- a/src/spawnfile/productionTerminal.ts +++ b/src/spawnfile/productionTerminal.ts @@ -1,23 +1,30 @@ -import { z } from "zod"; - import type { ComposedPhaseJournal } from "../compose/journal.js"; import { createComposedWorldTerminalReceipt, type ComposedRunningReceipt, } from "../compose/supervision.js"; import type { ComposedExecution } from "../compose/execution.js"; -import { readTargetPublicJson } from "./targetReceipts.js"; +import { parseComposedWorldTerminalSignal } from + "../world-artifact/terminalSignal.js"; +import { + isTargetPublicArtifactNotPresent, + readTargetPublicJson, +} from "./targetReceipts.js"; -const terminalSignal = z.object({ - outcome_digest: z.string().regex(/^sha256:[a-f0-9]{64}$/u), - reason: z.enum(["completed", "interrupted"]), - run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u), - terminal_tick: z.number().int().min(1).max(1_000_000_000), - version: z.literal("simfile.composed-world-terminal-signal.v1"), -}).strict(); -const waitForPoll = (signal: AbortSignal): Promise => new Promise((resolve, reject) => { +/** Exact retry signal for a terminal artifact that has not been published yet. */ +export class ProductionWorldTerminalNotPresentError extends Error { + public constructor() { + super("world terminal artifact is not present yet"); + this.name = "ProductionWorldTerminalNotPresentError"; + } +} + +const waitForPoll = ( + signal: AbortSignal, + pollIntervalMs: number, +): Promise => new Promise((resolve, reject) => { if (signal.aborted) { reject(signal.reason); return; } - const timer = setTimeout(done, 1_000); + const timer = setTimeout(done, pollIntervalMs); function done(): void { signal.removeEventListener("abort", aborted); resolve(); } function aborted(): void { clearTimeout(timer); signal.removeEventListener("abort", aborted); reject(signal.reason); @@ -28,14 +35,20 @@ const waitForPoll = (signal: AbortSignal): Promise => new Promise((resolve /** Polls only one world-owned public terminal artifact; participant state is absent. */ export const waitForProductionWorldTerminal = async (input: Readonly<{ journal: ComposedPhaseJournal; - provider: ComposedExecution["provider"]; + provider: Pick; run_target(command: string, request: Readonly>, signal: AbortSignal): Promise; running: ComposedRunningReceipt; selected_target: ComposedExecution["configuration"]["topology_expectation"]["selected_target"]; service_handle: string; signal: AbortSignal; + /** Internal test seam; production polling remains one second. */ + poll_interval_ms?: number; }>): Promise => { + const pollIntervalMs = input.poll_interval_ms ?? 1_000; + if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1 || pollIntervalMs > 60_000) { + throw new TypeError("world terminal poll interval is invalid"); + } const artifact = input.provider.terminal_artifact; const request = { artifact: { ...artifact, media_type: "application/json" }, @@ -49,13 +62,17 @@ export const waitForProductionWorldTerminal = async (input: Readonly<{ while (true) { try { raw = await input.run_target("snapshot_public_artifact", request, input.signal); + if (isTargetPublicArtifactNotPresent({ + artifact_id: artifact.id, raw, request, + })) throw new ProductionWorldTerminalNotPresentError(); break; - } catch { + } catch (error) { if (input.signal.aborted) throw input.signal.reason; - await waitForPoll(input.signal); + if (!(error instanceof ProductionWorldTerminalNotPresentError)) throw error; + await waitForPoll(input.signal, pollIntervalMs); } } - const observed = terminalSignal.parse(readTargetPublicJson({ + const observed = parseComposedWorldTerminalSignal(readTargetPublicJson({ artifact_id: artifact.id, raw, request, })); if (observed.run_id !== input.journal.request.run_id) { diff --git a/src/spawnfile/productionTopologyPorts.ts b/src/spawnfile/productionTopologyPorts.ts new file mode 100644 index 0000000..be685d6 --- /dev/null +++ b/src/spawnfile/productionTopologyPorts.ts @@ -0,0 +1,74 @@ +import { + createComposedTopologyActivationReceipt, + createComposedTopologyAttestationReceipt, + createComposedWorldTickReceipt, +} from "../compose/activation.js"; +import type { ComposedExecution } from "../compose/execution.js"; +import { composedPhasePayload } from "../compose/phase.js"; +import type { ComposedRunPorts } from "../compose/run.js"; +import { parseComposedWorldServiceReceipt } from "../compose/startup-world.js"; +import { + createProductionTargetDriver, + productionRecord as record, +} from "./productionTarget.js"; +import { verifyTargetWorldClockReceipt } from "./targetReceipts.js"; + +type Driver = ReturnType; + +export const createProductionTopologyPort = ( + execution: ComposedExecution, + driver: Driver, +): ComposedRunPorts["topology"] => { + const provider = execution.provider; + const { load, runTarget, selectedTarget, topologyRequest } = driver; + return { + attestTopology: async ({ organization_phase_digest, request_digest, signal, + world_phase_digest }) => { + const request = await topologyRequest(); + return createComposedTopologyAttestationReceipt({ organization_phase_digest, + request_digest, run_id: request.run_id, + target_topology: await runTarget("attest_topology", request, signal) as never, + world_phase_digest }); + }, + activateTopology: async ({ attestation, signal }) => { + const request = await topologyRequest(); + return createComposedTopologyActivationReceipt({ + attestation_receipt_digest: attestation.receipt_digest, run_id: request.run_id, + target_activation: await runTarget("activate_topology", request, signal) as never, + }); + }, + readFirstTick: async ({ activation, signal }) => { + const journal = await load(); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + const topology = record(record( + composedPhasePayload(journal, "topology_verified").attestation, + ).target_topology); + const targetActivation = activation.target_activation; + const request = { + activation_digest: targetActivation.activation_digest, + activation_receipt_digest: targetActivation.receipt_digest, + descriptor_digest: journal.request.descriptor_digest, + endpoint: { internal_port: provider.world_readiness_port, path: "/v1/world/clock" }, + expected: { document_version: "simfile.world-sidecar-clock.v1", + world_instance_id: execution.configuration.readiness_expectation.world_instance_id }, + run_id: journal.request.run_id, selected_target: selectedTarget, + topology_receipt_digest: topology.receipt_digest, + topology_request_digest: topology.request_digest, + version: "spawnfile.target-world-clock.request.v1", + world_service_handle: service.service_handle, + } as const; + const observed = verifyTargetWorldClockReceipt({ + raw: await runTarget("query_world_clock", request, signal), request, + }); + return createComposedWorldTickReceipt({ + activation_receipt_digest: activation.receipt_digest, clock: observed.clock, + run_id: journal.request.run_id, + world_phase_digest: journal.entries.find( + ({ phase }) => phase === "world_ready", + )!.payload_digest, + }); + }, + }; +}; diff --git a/src/spawnfile/productionWorldPorts.ts b/src/spawnfile/productionWorldPorts.ts new file mode 100644 index 0000000..d5069e9 --- /dev/null +++ b/src/spawnfile/productionWorldPorts.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +import type { ComposedExecution } from "../compose/execution.js"; +import type { ComposedJournalSession } from "../compose/journalSession.js"; +import { digestComposedJson } from "../compose/json.js"; +import { composedPhasePayload } from "../compose/phase.js"; +import type { ComposedRunPorts } from "../compose/run.js"; +import { + createComposedWorldResourceReceipt, + createComposedWorldServiceReceipt, +} from "../compose/startup-world.js"; +import type { ComposedTargetProvider } from "./composedTargetProvider.js"; +import { + createProductionTargetDriver, + productionRecord as record, +} from "./productionTarget.js"; +import { + parseTargetResourceReceipt, + verifyTargetReadinessReceipt, + verifyTargetResourceReceipt, +} from "./targetReceipts.js"; + +type Driver = ReturnType; +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); + +export const createProductionPreparationPort = (input: Readonly<{ + execution: ComposedExecution; + journal_session: ComposedJournalSession; + target_provider?: ComposedTargetProvider; +}>, driver: Driver): ComposedRunPorts["preparation"] => ({ + prepareComposedRun: async ({ idempotency_key, request, signal }) => { + await driver.guard(); + return driver.targetProvider.prepare({ + journal_session: input.journal_session, + request: { + auth_profile: request.target.auth_profile, + descriptor_digest: request.descriptor_digest, + idempotency_key, + organization: { + artifact_digest: request.organization.artifact_digest, + world_bindings_digest: request.organization.world_bindings_digest, + }, + run_id: request.run_id, + secret_bindings: input.execution.secret_bindings, + target_selector: request.target.selector, + version: "spawnfile.composed-preparation.request.v1", + world: { + artifact_manifest_digest: request.world.artifact_manifest_digest, + bundle_digest: request.world.bundle_digest, + }, + }, + signal, + }); + }, +}); + +export const createProductionWorldPort = ( + execution: ComposedExecution, + driver: Driver, +): ComposedRunPorts["world"] => { + const provider = execution.provider; + const { completeTarget, load, mutation, runTarget, selectedTarget } = driver; + return { + createWorldResource: async ({ idempotency_key, signal }) => { + const journal = await load(); + const preparation = record(composedPhasePayload(journal, "prepared").preparation); + const resources = record(preparation.resources); + const request = mutation(journal, "create_world_service", idempotency_key, 4, { + data_network_handle: record(resources.data_network).result_handle, + evidence_mount_path: provider.evidence_mount_path, + evidence_volume_handle: record(resources.evidence_volume).result_handle, + secret_bindings_handle: record(resources.secret_bindings).result_handle, + world_artifact_handle: record(resources.world_artifact).result_handle, + }); + const target = verifyTargetResourceReceipt({ operation: "create_world_service", + raw: await runTarget("create_world_service", request, signal), request, + resulting_revision: 5, run_id: journal.request.run_id }); + await completeTarget("create_world_service", request, target); + if (target.result_handle === null) throw new TypeError("world handle is missing"); + return createComposedWorldResourceReceipt({ + artifact_digest: journal.request.world.artifact_manifest_digest, + bundle_digest: journal.request.world.bundle_digest, + preparation_receipt_digest: z.string().parse(preparation.receipt_digest), + resource_handle: `opaque_${digestComposedJson( + "simfile.composed-world-resource-handle.v1", + { operation_handle: target.operation_handle, run_id: journal.request.run_id }, + ).slice(7, 39)}`, + run_id: journal.request.run_id, target_operation: target, + }); + }, + startWorldPaused: async ({ idempotency_key, resource, signal }) => { + const journal = await load(); + const created = parseTargetResourceReceipt(resource.target_operation); + if (created.result_handle === null) throw new TypeError("world service handle is missing"); + const request = mutation(journal, "start_world_service", idempotency_key, 5, + { world_service_handle: created.result_handle }); + const target = verifyTargetResourceReceipt({ operation: "start_world_service", + raw: await runTarget("start_world_service", request, signal), request, + resulting_revision: 6, run_id: journal.request.run_id }); + await completeTarget("start_world_service", request, target); + return createComposedWorldServiceReceipt({ resource_handle: resource.resource_handle, + run_id: journal.request.run_id, service_handle: handle.parse(target.result_handle), + target_operation: target }); + }, + readWorldReadiness: async ({ service, signal }) => { + const journal = await load(); + const { run_id: _runId, ...expected } = execution.configuration.readiness_expectation; + const request = { + descriptor_digest: journal.request.descriptor_digest, + endpoint: { internal_port: provider.world_readiness_port, path: "/v1/world/readiness" }, + expected: { ...expected, document_version: "simfile.world-sidecar-readiness.v1", + runtime_abi: journal.request.world.runtime_abi }, + run_id: journal.request.run_id, selected_target: selectedTarget, + version: "spawnfile.target-world-readiness.request.v1", + world_service_handle: service.service_handle, + }; + return verifyTargetReadinessReceipt({ + raw: await runTarget("query_world_readiness", request, signal), request, + }); + }, + }; +}; diff --git a/src/spawnfile/publicCapabilityContract.ts b/src/spawnfile/publicCapabilityContract.ts new file mode 100644 index 0000000..fa2face --- /dev/null +++ b/src/spawnfile/publicCapabilityContract.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import { digestComposedJson } from "../compose/json.js"; + +export const SPAWNFILE_CAPABILITIES_VERSION = "spawnfile.capabilities.v1" as const; +export const SPAWNFILE_COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION = + "spawnfile.composed-lifecycle-contract-set.v1" as const; +export const SPAWNFILE_ADMITTED_PACKAGE_VERSION = "0.1.17" as const; +const commandRowsDigest = "sha256:095db48660b286add81b00bdb084edc457f57b29c1c5b8a59c312e02560c4146"; + +const versioned = z.string().regex(/^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u); +const row = z.object({ argv: z.array(z.string().min(1).max(4_096)).min(1).max(32), + invocation_versions: z.array(versioned).max(32), pending_versions: z.array(versioned).max(32), + receipt_versions: z.array(versioned).max(32), request_versions: z.array(versioned).max(32), + stdin_versions: z.array(versioned).max(32), stdout: z.unknown() }).passthrough(); +const auxiliary = z.object({ + evidence_export_helper: z.object({ identity: z.literal("docker-image-config-digest"), + local_context_only: z.literal(true), prepare_command: z.tuple([z.literal("helper"), + z.literal("prepare-evidence-export"), z.literal("--context"), z.literal(""), z.literal("--json")]), + provisioning: z.literal("spawnfile-owned-target-local"), + receipt_version: z.literal("spawnfile.target-evidence-export-helper.prepared.v1"), + resolver_option: z.literal("--prepare-evidence-helper") }).strict(), + terminal_public_artifact: z.object({ not_present_version: + z.literal("spawnfile.target-public-artifact-snapshot.not-present.v1"), request_version: + z.literal("spawnfile.target-public-artifact-snapshot.request.v1"), snapshot_version: + z.literal("spawnfile.target-public-artifact-snapshot.v1") }).strict(), + target_config_resolver: z.object({ command: z.tuple([z.literal("target"), z.literal("resolve_config")]), + output_version: z.literal("spawnfile.target-config-resolution.v1"), prepared_plan_version: + z.literal("spawnfile.target-config-prepared-plan.v1"), target_config_digest_version: + z.literal("spawnfile.target-config-digest.v1"), target_config_version: + z.literal("spawnfile.target-default-config.v1") }).strict(), +}).passthrough(); + +export interface SpawnfileCapabilityCommandRow { 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: unknown; } +export interface SpawnfileCapabilitiesReceipt { readonly command_rows_digest: `sha256:${string}`; + readonly command_rows: readonly SpawnfileCapabilityCommandRow[]; + readonly command_set_version: typeof SPAWNFILE_COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION; + readonly implementation: Readonly<{ cli: "spawnfile"; package: "spawnfile"; version: string }>; + readonly version: typeof SPAWNFILE_CAPABILITIES_VERSION; } + +const rows = (raw: unknown): readonly SpawnfileCapabilityCommandRow[] => { + const record = z.record(z.string(), z.unknown()).parse(raw); + const candidate = ["command_rows", "commands", "operations", "rows"].filter((key) => Array.isArray(record[key])); + if (candidate.length !== 1) throw new TypeError("Spawnfile capabilities command rows are ambiguous or absent"); + const parsed = z.array(row).length(43).parse(record[candidate[0]!]); + const required = ["argv", "stdin_versions", "request_versions", "receipt_versions", "invocation_versions", "pending_versions", "stdout"]; + if (parsed.some((value) => !required.every((field) => Object.hasOwn(value, field)))) { + throw new TypeError("Spawnfile capabilities command row is incomplete"); + } + return Object.freeze(parsed.map((value) => Object.freeze({ argv: Object.freeze([...value.argv]), + invocation_versions: Object.freeze([...value.invocation_versions]), pending_versions: Object.freeze([...value.pending_versions]), + receipt_versions: Object.freeze([...value.receipt_versions]), request_versions: Object.freeze([...value.request_versions]), + stdin_versions: Object.freeze([...value.stdin_versions]), stdout: value.stdout }))); +}; + +/** Independently pins the exact published 0.1.17 public CLI contract. */ +export const parseSpawnfileCapabilitiesReceipt = (raw: unknown): SpawnfileCapabilitiesReceipt => { + const root = z.object({ capabilities: auxiliary.extend({ composed_lifecycle: z.object({ + command_set_version: z.literal(SPAWNFILE_COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION), complete: z.literal(true), + }).passthrough() }), implementation: z.object({ cli: z.literal("spawnfile"), package: z.literal("spawnfile"), + version: z.literal(SPAWNFILE_ADMITTED_PACKAGE_VERSION) }).strict(), + version: z.literal(SPAWNFILE_CAPABILITIES_VERSION) }).passthrough().parse(raw); + const commandRows = rows(root.capabilities.composed_lifecycle); + const digest = digestComposedJson("simfile.spawnfile-capability-contract.v1", commandRows); + if (digest !== commandRowsDigest) throw new TypeError("Spawnfile composed lifecycle command contract drifted"); + return Object.freeze({ command_rows: commandRows, command_rows_digest: digest, + command_set_version: root.capabilities.composed_lifecycle.command_set_version, + implementation: Object.freeze(root.implementation), version: root.version }); +}; diff --git a/src/spawnfile/publicCapabilityProbe.test.ts b/src/spawnfile/publicCapabilityProbe.test.ts new file mode 100644 index 0000000..879ea37 --- /dev/null +++ b/src/spawnfile/publicCapabilityProbe.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertSpawnfileCompositionCapabilities, + createSpawnfilePublicCapabilityProbe, + parseSpawnfileCapabilitiesReceipt, + probeSpawnfilePublicCapabilities, +} from "./publicCapabilityProbe.js"; + +const help = { + resolver_help: " --evidence-destination \n --prepared-plan \n", + root_help: " compile [options] [path]\n target [options]\n validate [path]\n", + target_help: " resolve_config [options]\n snapshot_public_artifact \n", + version: "0.1.14\n", +}; + +const capabilityRow = (index: number) => ({ + argv: [`command-${index}`], + invocation_versions: [], + pending_versions: [], + receipt_versions: ["spawnfile.generic-receipt.v1"], + request_versions: ["spawnfile.generic-request.v1"], + stdin_versions: [], + stdout: { format: "json" }, +}); + +const capabilities = (overrides: Record = {}) => ({ + capabilities: { + composed_lifecycle: { + command_rows: Array.from({ length: 43 }, (_, index) => capabilityRow(index)), + command_set_version: "spawnfile.composed-lifecycle-contract-set.v1", + complete: true, + }, + evidence_export_helper: { + identity: "docker-image-config-digest", local_context_only: true, + prepare_command: ["helper", "prepare-evidence-export", "--context", "", "--json"], + provisioning: "spawnfile-owned-target-local", + receipt_version: "spawnfile.target-evidence-export-helper.prepared.v1", + resolver_option: "--prepare-evidence-helper", + }, + target_config_resolver: { + command: ["target", "resolve_config"], output_version: "spawnfile.target-config-resolution.v1", + prepared_plan_version: "spawnfile.target-config-prepared-plan.v1", + target_config_digest_version: "spawnfile.target-config-digest.v1", + target_config_version: "spawnfile.target-default-config.v1", + }, + terminal_public_artifact: { + not_present_version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + request_version: "spawnfile.target-public-artifact-snapshot.request.v1", + snapshot_version: "spawnfile.target-public-artifact-snapshot.v1", + }, + }, + implementation: { cli: "spawnfile", package: "spawnfile", version: "0.1.17" }, + version: "spawnfile.capabilities.v1", + ...overrides, +}); + +test("legacy public capability probe fails closed on unverifiable generic semantics", () => { + const probe = createSpawnfilePublicCapabilityProbe(help); + assert.equal(probe.ready, false); + assert.deepEqual(probe.blockers, [ + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ]); + assert.throws(() => assertSpawnfileCompositionCapabilities(probe), + /generic_capabilities_receipt_unavailable/u); +}); + +test("capabilities receipt rejects a structurally-valid but unpinned command contract", () => { + assert.throws(() => parseSpawnfileCapabilitiesReceipt(capabilities()), + /command contract drifted/u); +}); + +test("capabilities receipt rejects incomplete rows, an incomplete set, and version drift", () => { + const incompleteRows = capabilities(); + (incompleteRows.capabilities.composed_lifecycle.command_rows as unknown[]).pop(); + assert.throws(() => parseSpawnfileCapabilitiesReceipt(incompleteRows), /expected array/u); + assert.throws(() => parseSpawnfileCapabilitiesReceipt(capabilities({ capabilities: { + ...capabilities().capabilities, + composed_lifecycle: { command_rows: Array.from({ length: 43 }, (_, index) => capabilityRow(index)), + command_set_version: "spawnfile.composed-lifecycle-contract-set.v1", complete: false }, + } })), /true/u); +}); + +test("public capability probing tries capabilities JSON before legacy help fallback", async () => { + const calls: string[] = []; + const outputs = new Map([ + ["--version", help.version], + ["--help", help.root_help], + ["target --help", help.target_help], + ["target resolve_config --help", help.resolver_help], + ]); + const probe = await probeSpawnfilePublicCapabilities({ + cwd: "/project", + environment: {}, + identity: { path: "/isolated/spawnfile", sha256: `sha256:${"a".repeat(64)}` }, + run: async (args) => { + const key = args.join(" "); + calls.push(key); + if (key === "capabilities --json") throw new Error("unsupported"); + return { stdout: outputs.get(key) ?? "" }; + }, + }); + assert.deepEqual(calls, [ + "--version", "capabilities --json", "--help", "target --help", "target resolve_config --help", + ]); + assert.equal(probe.ready, false); +}); + +test("capabilities JSON success uses no help inference", async () => { + const calls: string[] = []; + await assert.rejects(probeSpawnfilePublicCapabilities({ + cwd: "/project", + environment: {}, + identity: { path: "/isolated/spawnfile", sha256: `sha256:${"a".repeat(64)}` }, + run: async (args) => { + calls.push(args.join(" ")); + if (args[0] === "--version") return { stdout: "0.1.17\n" }; + return { stdout: JSON.stringify(capabilities()) }; + }, + }), /command contract drifted/u); +}); diff --git a/src/spawnfile/publicCapabilityProbe.ts b/src/spawnfile/publicCapabilityProbe.ts new file mode 100644 index 0000000..3a708a9 --- /dev/null +++ b/src/spawnfile/publicCapabilityProbe.ts @@ -0,0 +1,167 @@ +import type { BootstrapLocalExecutableIdentity } from "./process.js"; +import { runSpawnfileProcess } from "./process.js"; +import { parseSpawnfileCapabilitiesReceipt, type SpawnfileCapabilitiesReceipt } from "./publicCapabilityContract.js"; + +export { parseSpawnfileCapabilitiesReceipt, SPAWNFILE_ADMITTED_PACKAGE_VERSION, + SPAWNFILE_CAPABILITIES_VERSION, SPAWNFILE_COMPOSED_LIFECYCLE_CONTRACT_SET_VERSION, + type SpawnfileCapabilitiesReceipt, type SpawnfileCapabilityCommandRow } from "./publicCapabilityContract.js"; + +export const SPAWNFILE_PUBLIC_CAPABILITY_PROBE_VERSION = + "simfile.spawnfile-public-capability-probe.v1" as const; +const semanticVersion = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u; +const parseVersion = (value: string): string => { + if (!semanticVersion.test(value)) throw new TypeError("Spawnfile did not report a semantic version"); + return value; +}; + +export interface SpawnfilePublicCapabilityProbe { + readonly blockers: readonly string[]; + readonly capabilities?: SpawnfileCapabilitiesReceipt; + readonly commands: Readonly>; + readonly implementation: Readonly<{ package: "spawnfile"; version: string }>; + readonly ready: boolean; + readonly resolver: Readonly>; + readonly version: typeof SPAWNFILE_PUBLIC_CAPABILITY_PROBE_VERSION; +} + +const helpHasToken = (help: string, token: string): boolean => + help.split(/\r?\n/u).some((line) => { + const normalized = line.trim(); + return normalized === token || normalized.startsWith(`${token} `); + }); +const command = (help: string, name: string): boolean => helpHasToken(help, name); +const option = (help: string, name: string): boolean => helpHasToken(help, `--${name}`); + +const parseJson = (source: string): unknown => { + try { return JSON.parse(source) as unknown; } + catch { throw new TypeError("Spawnfile capabilities did not emit JSON"); } +}; + +const legacyProbe = (input: Readonly<{ + resolver_help: string; + root_help: string; + target_help: string; + version: string; +}>): SpawnfilePublicCapabilityProbe => { + const version = parseVersion(input.version.trim()); + const commands = Object.freeze({ + compile: command(input.root_help, "compile"), + resolve_config: command(input.target_help, "resolve_config"), + snapshot_public_artifact: command(input.target_help, "snapshot_public_artifact"), + target: command(input.root_help, "target"), + validate: command(input.root_help, "validate"), + }); + const resolver = Object.freeze({ + evidence_destination: option(input.resolver_help, "evidence-destination"), + prepared_plan: option(input.resolver_help, "prepared-plan"), + }); + const blockers = Object.entries(commands).filter(([, available]) => !available) + .map(([name]) => `generic_command_unavailable:${name}`); + if (!resolver.evidence_destination) { + blockers.push("generic_resolver_option_unavailable:evidence_destination"); + } + if (!resolver.prepared_plan) { + blockers.push("generic_resolver_option_unavailable:prepared_plan"); + } + blockers.push( + "generic_capabilities_receipt_unavailable", + "evidence_export_helper_capability_unverifiable", + "typed_terminal_not_present_capability_unverifiable", + ); + return Object.freeze({ + blockers: Object.freeze(blockers), + commands, + implementation: Object.freeze({ package: "spawnfile" as const, version }), + ready: false, + resolver, + version: SPAWNFILE_PUBLIC_CAPABILITY_PROBE_VERSION, + }); +}; + +/** + * Classifies a generic capability receipt. Simfile parses every declared + * command-row field and admits only the pinned packaged contract wired to the + * journal-owned provider seam. + */ +export const createSpawnfilePublicCapabilityProbe = (input: Readonly<{ + capabilities_json?: string; + resolver_help: string; + root_help: string; + target_help: string; + version: string; +}>): SpawnfilePublicCapabilityProbe => { + if (input.capabilities_json === undefined) return legacyProbe(input); + const capabilities = parseSpawnfileCapabilitiesReceipt(parseJson(input.capabilities_json)); + const version = parseVersion(input.version.trim()); + const blockers: string[] = []; + if (capabilities.implementation.version !== version) { + blockers.push("capabilities_implementation_version_mismatch"); + } + return Object.freeze({ + blockers: Object.freeze(blockers), + capabilities, + commands: Object.freeze({ capabilities: true }), + implementation: Object.freeze({ package: "spawnfile" as const, version }), + ready: blockers.length === 0, + resolver: Object.freeze({ generic_capabilities_receipt: true }), + version: SPAWNFILE_PUBLIC_CAPABILITY_PROBE_VERSION, + }); +}; + +type ProbeRunner = ( + args: readonly string[], + signal: AbortSignal | undefined, +) => Promise>; + +/** Invokes only bounded version/help/capability discovery commands; no mutation occurs. */ +export const probeSpawnfilePublicCapabilities = async (input: Readonly<{ + cwd: string; + environment: NodeJS.ProcessEnv; + identity: BootstrapLocalExecutableIdentity; + run?: ProbeRunner; + signal?: AbortSignal; +}>): Promise => { + const run: ProbeRunner = input.run ?? (async (args, signal) => + runSpawnfileProcess({ + bootstrapLocalExecutableIdentity: input.identity, + cwd: input.cwd, + env: input.environment, + spawnfileBin: input.identity.path, + timeoutMs: 10_000, + }, { args, signal })); + const [version, capabilityResult] = await Promise.all([ + run(["--version"], input.signal), + run(["capabilities", "--json"], input.signal).then((result) => result.stdout).catch(() => undefined), + ]); + if (capabilityResult !== undefined) { + return createSpawnfilePublicCapabilityProbe({ + capabilities_json: capabilityResult, + resolver_help: "", + root_help: "", + target_help: "", + version: version.stdout, + }); + } + const unavailable = Object.freeze({ stdout: "" }); + const [rootHelp, targetHelp, resolverHelp] = await Promise.all([ + run(["--help"], input.signal), + run(["target", "--help"], input.signal).catch(() => unavailable), + run(["target", "resolve_config", "--help"], input.signal).catch(() => unavailable), + ]); + return legacyProbe({ + resolver_help: resolverHelp.stdout, + root_help: rootHelp.stdout, + target_help: targetHelp.stdout, + version: version.stdout, + }); +}; + +export const assertSpawnfileCompositionCapabilities = ( + probe: SpawnfilePublicCapabilityProbe, +): void => { + if (!probe.ready) { + throw new TypeError( + `Simfile cannot verify the generic Spawnfile capabilities required for composition (${probe.blockers.join(", ")})`, + ); + } +}; diff --git a/src/spawnfile/publicSurface.test.ts b/src/spawnfile/publicSurface.test.ts index bf01d6a..8ea1d25 100644 --- a/src/spawnfile/publicSurface.test.ts +++ b/src/spawnfile/publicSurface.test.ts @@ -46,10 +46,14 @@ test("spawnfile public surface is the exact neutral external-consumer contract", await writeFile(sourcePath, [ 'import { assertSpawnfileAuthProfileName, parseSpawnfileComposedPreparationRequest, parseSpawnfileExportResult } from "simfile/spawnfile";', 'import type { RunSpawnfileArtifactsExportInput, RunSpawnfileComposedPreparationInput, RunSpawnfileDownInput, RunSpawnfileUpInput, SpawnfileCliContext, SpawnfileComposedPreparationReceipt, SpawnfileComposedPreparationRequest, SpawnfileDownReceipt, SpawnfileExportIndexFile, SpawnfileExportResult, SpawnfileUpReceipt } from "simfile/spawnfile";', + '// @ts-expect-error bootstrap identity is internal to composed bootstrap', + 'import type { BootstrapSpawnfileCliContext } from "simfile/spawnfile";', 'const digest = "a".repeat(64);', 'const file: SpawnfileExportIndexFile = { path: "raw/events.jsonl", sha256: digest };', 'const result: SpawnfileExportResult = parseSpawnfileExportResult({ deployment: "run", index_path: "/tmp/index.json", index: { version: "spawnfile.export-index.v1", run_id: "run", deployment: "run", exported_at: "2026-01-01T00:00:00.000Z", files: [file] } });', 'const context: SpawnfileCliContext = { spawnfileBin: "spawnfile.mjs" };', + '// @ts-expect-error bootstrap identity is not part of the public context', + 'const bootstrapContext: SpawnfileCliContext = { bootstrapLocalExecutableIdentity: {}, spawnfileBin: "spawnfile.mjs" };', 'const up: RunSpawnfileUpInput = { orgPath: "org", containerName: "container", deploymentName: "run", compiledOutputDirectory: "compiled", descriptorDigest: `sha256:${digest}`, dockerContext: "target", envFile: "/tmp/env", imageTag: "organization:run", networkAttachmentHandle: "opaque_abcdefghijklmnop", organizationHandoffRunId: "run", selectedTargetReceiptDigest: `sha256:${digest}`, selectedTargetReceiptFile: "/tmp/target.json", worldBindingsFile: "/tmp/world.json" };', 'const exported: RunSpawnfileArtifactsExportInput = { orgPath: "org", deploymentName: "run", compiledOutputDirectory: "compiled", destinationDirectory: "out" };', 'const down: RunSpawnfileDownInput = { orgPath: "org", deploymentName: "run", compiledOutputDirectory: "compiled" };', @@ -58,7 +62,7 @@ test("spawnfile public surface is the exact neutral external-consumer contract", 'const preparationRequest = undefined as unknown as SpawnfileComposedPreparationRequest;', 'const preparationInput = undefined as unknown as RunSpawnfileComposedPreparationInput;', 'const preparationReceipt = undefined as unknown as SpawnfileComposedPreparationReceipt;', - 'void [context, up, exported, down, upReceipt, downReceipt, preparationRequest, preparationInput, preparationReceipt];', + 'void [context, bootstrapContext, up, exported, down, upReceipt, downReceipt, preparationRequest, preparationInput, preparationReceipt];', 'void parseSpawnfileComposedPreparationRequest;', 'assertSpawnfileAuthProfileName("safe-profile_1");', 'if (result.index.files[0]?.sha256 !== digest) throw new Error("receipt parser changed");' diff --git a/src/spawnfile/spawnfileCliShared.ts b/src/spawnfile/spawnfileCliShared.ts new file mode 100644 index 0000000..b6a351a --- /dev/null +++ b/src/spawnfile/spawnfileCliShared.ts @@ -0,0 +1,38 @@ +import { runSpawnfileProcess, type SpawnfileCliContext } from "./process.js"; + +const AUTH_PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; + +export const assertSpawnfileAuthProfileName = (value: string | undefined): void => { + if (value !== undefined && !AUTH_PROFILE_NAME.test(value)) { + throw new Error("spawnfile auth profile name is not a safe identifier"); + } +}; + +export const assertLifecycleInvocation = (value: string): void => { + if (!/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u.test(value)) { + throw new Error("spawnfile lifecycle invocation id is invalid"); + } +}; + +export const execSpawnfile = ( + context: SpawnfileCliContext, + args: readonly string[], + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string }> => runSpawnfileProcess(context, { args, signal }); + +export const execSpawnfileWithStdin = ( + context: SpawnfileCliContext, + args: readonly string[], + stdin: Uint8Array, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string }> => runSpawnfileProcess(context, { + args, signal, stdin, +}); + +export const parseSpawnfileJson = (stdout: string, label = "CLI"): unknown => { + try { return JSON.parse(stdout.trim()) as unknown; } + catch { + // JSON parser diagnostics can quote attacker-controlled stdout bytes. + throw new Error(`spawnfile ${label} did not print valid JSON`); + } +}; diff --git a/src/spawnfile/targetBootstrap.ts b/src/spawnfile/targetBootstrap.ts new file mode 100644 index 0000000..8a50a34 --- /dev/null +++ b/src/spawnfile/targetBootstrap.ts @@ -0,0 +1,174 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { + currentBootstrapOperation, + journalBootstrapOperationIntent, + journalBootstrapOperationObservation, +} from "../compose/bootstrapOperationJournal.js"; +import { canonicalComposedJson } from "../compose/json.js"; +import type { ComposedJournalSession } from "../compose/journalSession.js"; +import { + parseSpawnfileBundleReceipt, + runSpawnfileContainerBundle, + runSpawnfileContainerBundleLookup, + spawnfileBundleRequestDigest, + type SpawnfileBundleRequest, +} from "./containerBundleCli.js"; +import { + createCliComposedTargetProvider, + type CliComposedTargetProvider, +} from "./composedTargetProvider.js"; +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; +import { + parseSpawnfileTargetConfigResolution, + type SpawnfileTargetConfigResolution, +} from "./targetConfigResolution.js"; +import { SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS } from "./targetConfigPreview.js"; +import { + parseSpawnfileSelectedTarget, + runSpawnfileSelectTarget, + type SpawnfileSelectedTarget, +} from "./targetSelection.js"; + +const replace = async (session: ComposedJournalSession, next: ReturnType< + typeof journalBootstrapOperationObservation +>): Promise => session.replace(session.current(), next); + +const observe = async (session: ComposedJournalSession, operationId: string, + state: "completed" | "lookup_required", receipt?: Readonly>) => + replace(session, journalBootstrapOperationObservation( + session.current(), operationId, state, receipt, + )); + +const resolverArgs = (input: JournaledTargetBootstrapInput): string[] => { + const args = ["target", "resolve_config", "--context", input.local_context, + "--evidence-destination", input.evidence_destination, + "--prepared-plan", input.prepared_plan, "--prepare-evidence-helper", + "--timeout-ms", String(SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS)]; + if (input.base_image !== "node:22-bookworm-slim") args.push("--base-image", input.base_image); + if (input.docker_command !== "docker") args.push("--docker-command", input.docker_command); + return args; +}; + +export interface JournaledTargetBootstrapInput { + readonly base_image: string; + readonly create_bundle_request: ( + selected: SpawnfileSelectedTarget, + ) => SpawnfileBundleRequest; + readonly context: BootstrapSpawnfileCliContext; + readonly docker_command: string; + readonly evidence_destination: string; + readonly journal_session: ComposedJournalSession; + readonly local_context: string; + readonly prepared_plan: string; + readonly select_request: Readonly>; + readonly signal?: AbortSignal; +} + +const resolveTarget = async ( + input: JournaledTargetBootstrapInput, +): Promise => { + const request = { base_image: input.base_image, context: input.local_context, + docker_command: input.docker_command, evidence_destination: input.evidence_destination, + prepared_plan_sha256: `sha256:${createHash("sha256") + .update(await readFile(input.prepared_plan)).digest("hex")}` }; + let operation = currentBootstrapOperation(input.journal_session.current(), "resolve_target_config"); + if (operation === undefined) { + const current = input.journal_session.current(); + await input.journal_session.replace(current, + journalBootstrapOperationIntent(current, "resolve_target_config", request)); + operation = currentBootstrapOperation(input.journal_session.current(), "resolve_target_config")!; + } + try { + const raw = await runSpawnfileProcess(input.context, { + args: resolverArgs(input), signal: input.signal, + }); + const resolution = parseSpawnfileTargetConfigResolution( + JSON.parse(raw.stdout) as unknown, input.local_context, + ); + if (operation.state === "completed" + && canonicalComposedJson(operation.receipt) !== canonicalComposedJson(resolution.identity)) { + resolution.config_bytes.fill(0); + throw new TypeError("Spawnfile target configuration resolution changed"); + } + if (operation.state !== "completed") { + await observe(input.journal_session, operation.operation_id, "completed", resolution.identity); + } + return resolution; + } catch (error) { + const current = currentBootstrapOperation(input.journal_session.current(), "resolve_target_config"); + if (current !== undefined && current.state !== "completed") { + await observe(input.journal_session, current.operation_id, "lookup_required"); + } + throw error; + } +}; + +const selectTarget = async (input: JournaledTargetBootstrapInput, + resolution: SpawnfileTargetConfigResolution): Promise => { + let operation = currentBootstrapOperation(input.journal_session.current(), "select_target"); + if (operation?.state === "completed") return parseSpawnfileSelectedTarget(operation.receipt); + if (operation === undefined) { + const current = input.journal_session.current(); + await input.journal_session.replace(current, + journalBootstrapOperationIntent(current, "select_target", input.select_request)); + operation = currentBootstrapOperation(input.journal_session.current(), "select_target")!; + } + try { + const receipt = await runSpawnfileSelectTarget({ context: input.context, + request: input.select_request, signal: input.signal, + target_config: resolution.config_bytes }); + await observe(input.journal_session, operation.operation_id, "completed", receipt); + return receipt; + } catch (error) { + await observe(input.journal_session, operation.operation_id, "lookup_required"); + throw error; + } +}; + +const prepareBundle = async (input: JournaledTargetBootstrapInput, + resolution: SpawnfileTargetConfigResolution, selected: SpawnfileSelectedTarget) => { + const request = input.create_bundle_request(selected); + const summary = { idempotency_key: request.idempotency_key, + request_digest: spawnfileBundleRequestDigest(request) }; + let operation = currentBootstrapOperation(input.journal_session.current(), "prepare_container_bundle"); + if (operation?.state === "completed") return parseSpawnfileBundleReceipt(operation.receipt); + if (operation === undefined) { + const current = input.journal_session.current(); + await input.journal_session.replace(current, + journalBootstrapOperationIntent(current, "prepare_container_bundle", summary)); + operation = currentBootstrapOperation(input.journal_session.current(), "prepare_container_bundle")!; + } + try { + const lookup = await runSpawnfileContainerBundleLookup({ context: input.context, + ...summary, signal: input.signal, target_config: resolution.config_bytes }); + const receipt = lookup.status === "completed" ? lookup.receipt + : await runSpawnfileContainerBundle({ command: lookup.status === "pending" + ? "recover_container_bundle" : "prepare_container_bundle", context: input.context, + request, signal: input.signal, target_config: resolution.config_bytes }); + await observe(input.journal_session, operation.operation_id, "completed", receipt); + return receipt; + } catch (error) { + await observe(input.journal_session, operation.operation_id, "lookup_required"); + throw error; + } +}; + +export const bootstrapJournaledTarget = async (input: JournaledTargetBootstrapInput): Promise<{ + provider: CliComposedTargetProvider; + resolution: SpawnfileTargetConfigResolution["identity"]; + selected_target: SpawnfileSelectedTarget; +}> => { + const resolution = await resolveTarget(input); + try { + const selected = await selectTarget(input, resolution); + await prepareBundle(input, resolution, selected); + const provider = await createCliComposedTargetProvider({ ...input, + expected_resolution: resolution.identity, resolved_resolution: resolution }); + return Object.freeze({ provider, resolution: resolution.identity, selected_target: selected }); + } catch (error) { + resolution.config_bytes.fill(0); + throw error; + } +}; diff --git a/src/spawnfile/targetCommandCli.ts b/src/spawnfile/targetCommandCli.ts new file mode 100644 index 0000000..5b0edfe --- /dev/null +++ b/src/spawnfile/targetCommandCli.ts @@ -0,0 +1,38 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { assertSecretFreeComposedJson } from "../compose/json.js"; +import type { SpawnfileCliContext } from "./process.js"; +import { execSpawnfileWithStdin, parseSpawnfileJson } from "./spawnfileCliShared.js"; + +const targetCommand = /^(?:attach_organization|cleanup_run|create_world_service|detach_organization|export_evidence_volume|lookup_operation|query_world_clock|query_world_readiness|recover_operation|revoke_secret_bindings|snapshot_public_artifact|start_world_service|stop_world_service|attest_topology|activate_topology)$/u; + +export const runSpawnfileTargetCommand = async ( + context: SpawnfileCliContext, + input: Readonly<{ command: string; request: unknown; signal?: AbortSignal; + targetConfigStdin: string | Uint8Array }>, +): Promise => { + if (!targetCommand.test(input.command)) { + throw new Error("spawnfile target command input is invalid"); + } + assertSecretFreeComposedJson(input.request); + const config = typeof input.targetConfigStdin === "string" + ? new TextEncoder().encode(input.targetConfigStdin) + : Uint8Array.from(input.targetConfigStdin); + if (config.byteLength < 1 || config.byteLength > 262_144) { + throw new Error("spawnfile target configuration stdin is invalid"); + } + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-spawnfile-target-")); + try { + const requestFile = path.join(root, "request.json"); + await writeFile(requestFile, `${JSON.stringify(input.request)}\n`, { mode: 0o600 }); + const { stdout } = await execSpawnfileWithStdin(context, [ + "target", "--config", "-", input.command, requestFile, + ], config, input.signal); + return parseSpawnfileJson(stdout); + } finally { + config.fill(0); + await rm(root, { force: true, recursive: true }); + } +}; diff --git a/src/spawnfile/targetConfigPreview.ts b/src/spawnfile/targetConfigPreview.ts new file mode 100644 index 0000000..35c2681 --- /dev/null +++ b/src/spawnfile/targetConfigPreview.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const context = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +export const SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS = 120_000; +const preview = z.object({ + base_image: z.object({ config_digest: digest, + reference: z.string().min(1).max(512) }).strict(), + context_selection: z.literal("explicit"), + endpoint: z.object({ class: z.literal("local"), + transport: z.enum(["fd", "npipe", "unix"]) }).strict(), + platform: z.object({ architecture: z.enum(["amd64", "arm64"]), + os: z.literal("linux") }).strict(), + target_config: z.object({ context, + version: z.literal("spawnfile.target-default-config.v1") }).passthrough(), + target_config_digest: digest, + version: z.literal("spawnfile.target-config-resolution.v1"), +}).strict(); + +export type SpawnfileTargetConfigPreview = Readonly<{ + base_image: Readonly<{ config_digest: `sha256:${string}`; reference: string }>; + context: string; + endpoint_transport: "fd" | "npipe" | "unix"; + platform: Readonly<{ architecture: "amd64" | "arm64"; os: "linux" }>; +}>; + +export const runSpawnfileTargetConfigPreview = async (input: Readonly<{ + base_image: string; + context: BootstrapSpawnfileCliContext; + docker_command: string; + evidence_destination: string; + local_context: string; + signal?: AbortSignal; +}>): Promise => { + const args = ["target", "resolve_config", "--context", input.local_context, + "--evidence-destination", input.evidence_destination, + "--timeout-ms", String(SPAWNFILE_TARGET_DOCKER_TIMEOUT_MS)]; + if (input.base_image !== "node:22-bookworm-slim") args.push("--base-image", input.base_image); + if (input.docker_command !== "docker") args.push("--docker-command", input.docker_command); + const result = preview.parse(JSON.parse((await runSpawnfileProcess(input.context, { + args, signal: input.signal, + })).stdout) as unknown); + if (result.target_config.context !== input.local_context) { + throw new TypeError("Spawnfile target preview context changed"); + } + return Object.freeze({ + base_image: Object.freeze({ ...result.base_image }) as SpawnfileTargetConfigPreview["base_image"], + context: input.local_context, + endpoint_transport: result.endpoint.transport, + platform: Object.freeze(result.platform), + }); +}; diff --git a/src/spawnfile/targetConfigResolution.ts b/src/spawnfile/targetConfigResolution.ts new file mode 100644 index 0000000..4c1967b --- /dev/null +++ b/src/spawnfile/targetConfigResolution.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +import { canonicalComposedJson, digestComposedJson } from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const context = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const helper = z.object({ + digest, + handle: z.string().regex(/^opaque_[a-z0-9]{16,64}$/u), + version: z.literal("spawnfile.target-evidence-export-helper.prepared.v1"), +}).strict(); +const platform = z.object({ + architecture: z.enum(["amd64", "arm64"]), + os: z.literal("linux"), +}).strict(); +const targetConfig = z.object({ + context, + preparedEvidenceHelper: helper, + version: z.literal("spawnfile.target-default-config.v1"), +}).passthrough(); +const resolution = z.object({ + base_image: z.object({ config_digest: digest, reference: z.string().min(1).max(512) }).strict(), + context_selection: z.literal("explicit"), + endpoint: z.object({ + class: z.literal("local"), + transport: z.enum(["fd", "npipe", "unix"]), + }).strict(), + platform, + prepared_evidence_helper: helper, + target_config: targetConfig, + target_config_digest: digest, + version: z.literal("spawnfile.target-config-resolution.v1"), +}).strict(); + +export interface SpawnfileTargetConfigResolution { + readonly config_bytes: Uint8Array; + readonly identity: Readonly<{ + base_image: Readonly<{ config_digest: `sha256:${string}`; reference: string }>; + context: string; + endpoint_transport: "fd" | "npipe" | "unix"; + platform: Readonly<{ architecture: "amd64" | "arm64"; os: "linux" }>; + prepared_evidence_helper: Readonly<{ + digest: `sha256:${string}`; + handle: string; + version: "spawnfile.target-evidence-export-helper.prepared.v1"; + }>; + target_config_digest: `sha256:${string}`; + version: "spawnfile.target-config-resolution.v1"; + }>; +} + +/** Verifies the public receipt while retaining private config bytes only in memory. */ +export const parseSpawnfileTargetConfigResolution = ( + raw: unknown, + expectedContext: string, +): SpawnfileTargetConfigResolution => { + const value = resolution.parse(raw); + if (value.target_config.context !== expectedContext + || value.prepared_evidence_helper.handle + !== value.target_config.preparedEvidenceHelper.handle + || value.prepared_evidence_helper.digest + !== value.target_config.preparedEvidenceHelper.digest + || value.target_config_digest !== digestComposedJson( + "spawnfile.target-config-digest.v1", value.target_config, + )) { + throw new TypeError("Spawnfile target configuration resolution correlation is invalid"); + } + return Object.freeze({ + config_bytes: new TextEncoder().encode(canonicalComposedJson(value.target_config)), + identity: Object.freeze({ + base_image: Object.freeze({ + config_digest: value.base_image.config_digest as `sha256:${string}`, + reference: value.base_image.reference, + }), + context: expectedContext, + endpoint_transport: value.endpoint.transport, + platform: Object.freeze(value.platform), + prepared_evidence_helper: Object.freeze({ + digest: value.prepared_evidence_helper.digest as `sha256:${string}`, + handle: value.prepared_evidence_helper.handle, + version: value.prepared_evidence_helper.version, + }), + target_config_digest: value.target_config_digest as `sha256:${string}`, + version: value.version, + }), + }); +}; diff --git a/src/spawnfile/targetOperationLookup.test.ts b/src/spawnfile/targetOperationLookup.test.ts new file mode 100644 index 0000000..9195601 --- /dev/null +++ b/src/spawnfile/targetOperationLookup.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { digestComposedJson } from "../compose/json.js"; +import { parseTargetOperationLookup } from "./targetOperationLookup.js"; + +const request = { + descriptor_digest: `sha256:${"1".repeat(64)}`, + expected_revision: 0, + idempotency_key: "idem_aaaaaaaaaaaaaaaa", + operation: "create_data_network", + run_id: "run-one", + selected_target: { + fingerprint: `sha256:${"2".repeat(32)}`, + handle: "opaque_bbbbbbbbbbbbbbbb", + }, + version: "spawnfile.target-resource.request.v1", +} as const; +const requestDigest = digestComposedJson( + "spawnfile.target-resource.request.v1", request, +); +const common = { idempotency_key: request.idempotency_key, + operation: request.operation, request_digest: requestDigest, + version: "spawnfile.target-resource.operation-lookup.v1" } as const; + +test("target lookup preserves typed pending and validates completed correlation", () => { + const pending = parseTargetOperationLookup({ ...common, + operation_handle: "opaque_cccccccccccccccc", status: "pending" }, request); + assert.equal(pending.status, "pending"); + assert.equal(pending.operation_handle, "opaque_cccccccccccccccc"); + const receipt = { operation: request.operation, + operation_handle: "opaque_cccccccccccccccc", request_digest: requestDigest }; + assert.deepEqual(parseTargetOperationLookup({ ...common, + operation_handle: receipt.operation_handle, receipt, status: "completed" }, request), { + operation_handle: receipt.operation_handle, request_digest: requestDigest, + status: "completed", target_receipt: receipt, + }); + assert.throws(() => parseTargetOperationLookup({ ...common, + operation_handle: receipt.operation_handle, + receipt: { ...receipt, operation: "cleanup_run" }, status: "completed" }, request), + /uncorrelated/u); +}); + +test("target lookup rejects drift and contradictory wire shapes", () => { + assert.deepEqual(parseTargetOperationLookup({ ...common, + status: "not_applied" }, request).status, "not_applied"); + for (const forged of [ + { ...common, idempotency_key: "idem_dddddddddddddddd", status: "not_applied" }, + { ...common, operation: "cleanup_run", status: "not_applied" }, + { ...common, operation_handle: "opaque_cccccccccccccccc", + status: "not_applied" }, + ]) assert.throws(() => parseTargetOperationLookup(forged, request)); +}); diff --git a/src/spawnfile/targetOperationLookup.ts b/src/spawnfile/targetOperationLookup.ts new file mode 100644 index 0000000..4c09b5f --- /dev/null +++ b/src/spawnfile/targetOperationLookup.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { + assertSecretFreeComposedJson, + canonicalComposedJson, + digestComposedJson, +} from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const base = { + idempotency_key: z.string().regex(/^idem_[a-z0-9]{16,64}$/u), + operation: z.string().regex(/^[a-z][a-z_]{1,63}$/u), + request_digest: digest, + version: z.literal("spawnfile.target-resource.operation-lookup.v1"), +}; +const lookup = z.discriminatedUnion("status", [ + z.object({ ...base, status: z.literal("not_applied") }).strict(), + z.object({ ...base, operation_handle: handle, + status: z.literal("pending") }).strict(), + z.object({ ...base, operation_handle: handle, + receipt: z.record(z.string(), z.unknown()), + status: z.literal("completed") }).strict(), +]); + +export interface TargetOperationLookup { + readonly operation_handle?: string; + readonly request_digest: `sha256:${string}`; + readonly status: "completed" | "not_applied" | "pending"; + readonly target_receipt?: Readonly>; +} + +/** Independently verifies Spawnfile's exact lookup correlation envelope. */ +export const parseTargetOperationLookup = ( + raw: unknown, + request: Readonly>, +): TargetOperationLookup => { + assertSecretFreeComposedJson(raw); + const value = lookup.parse(raw); + const expected = digestComposedJson("spawnfile.target-resource.request.v1", request); + if (value.request_digest !== expected + || value.idempotency_key !== request.idempotency_key + || value.operation !== request.operation) { + throw new TypeError("target operation lookup correlation is invalid"); + } + if (value.status === "completed") { + const receipt = value.receipt; + if (receipt.request_digest !== value.request_digest + || receipt.operation !== value.operation + || receipt.operation_handle !== value.operation_handle) { + throw new TypeError("target operation lookup receipt is uncorrelated"); + } + return Object.freeze({ operation_handle: value.operation_handle, + request_digest: value.request_digest, status: value.status, + target_receipt: Object.freeze(JSON.parse( + canonicalComposedJson(receipt), + ) as Record) }); + } + return Object.freeze({ + ...(value.status === "pending" ? { operation_handle: value.operation_handle } : {}), + request_digest: value.request_digest, + status: value.status, + }); +}; diff --git a/src/spawnfile/targetPublicArtifact.ts b/src/spawnfile/targetPublicArtifact.ts new file mode 100644 index 0000000..4e39e3b --- /dev/null +++ b/src/spawnfile/targetPublicArtifact.ts @@ -0,0 +1,60 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const publicArtifact = z.object({ artifact_id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), + content_base64: z.string().max(Math.ceil(131_072 / 3) * 4), content_digest: digest, + media_type: z.string().min(1).max(127), request_digest: digest, run_id: runId, + size_bytes: z.number().int().min(0).max(131_072), + version: z.literal("spawnfile.target-public-artifact-snapshot.v1") }).strict(); +const notPresent = z.object({ artifact_id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), + request_digest: digest, run_id: runId, status: z.literal("not_present"), + version: z.literal("spawnfile.target-public-artifact-snapshot.not-present.v1") }).strict(); + +interface ReadInput { artifact_id: string; raw: unknown; + request: Readonly> } + +export const isTargetPublicArtifactNotPresent = (input: Readonly): boolean => { + assertSecretFreeComposedJson(input.raw); + const receipt = notPresent.safeParse(input.raw); + if (!receipt.success) return false; + const artifact = input.request.artifact as Readonly<{ id?: unknown }> | undefined; + return receipt.data.artifact_id === input.artifact_id + && receipt.data.artifact_id === artifact?.id + && receipt.data.run_id === input.request.run_id + && receipt.data.request_digest === digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", input.request, + ); +}; + +export const readTargetPublicBytes = (input: Readonly): Buffer => { + assertSecretFreeComposedJson(input.raw); + const receipt = publicArtifact.parse(input.raw); + const bytes = Buffer.from(receipt.content_base64, "base64"); + try { + const artifact = input.request.artifact as Readonly<{ + id?: unknown; max_bytes?: unknown; media_type?: unknown; + }> | undefined; + if (bytes.toString("base64") !== receipt.content_base64 + || bytes.byteLength !== receipt.size_bytes || receipt.artifact_id !== input.artifact_id + || receipt.artifact_id !== artifact?.id || receipt.run_id !== input.request.run_id + || receipt.media_type !== artifact?.media_type || !Number.isSafeInteger(artifact?.max_bytes) + || receipt.size_bytes > (artifact?.max_bytes as number) + || receipt.content_digest !== `sha256:${createHash("sha256").update(bytes).digest("hex")}` + || receipt.request_digest !== digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", input.request, + )) throw new TypeError("Spawnfile public artifact correlation is invalid"); + return bytes; + } catch (error) { bytes.fill(0); throw error; } +}; + +export const readTargetPublicJson = (input: Readonly): unknown => { + const bytes = readTargetPublicBytes(input); + try { return JSON.parse(bytes.toString("utf8")) as unknown; } + finally { bytes.fill(0); } +}; diff --git a/src/spawnfile/targetReceipts.test.ts b/src/spawnfile/targetReceipts.test.ts index 36282d0..0c404ec 100644 --- a/src/spawnfile/targetReceipts.test.ts +++ b/src/spawnfile/targetReceipts.test.ts @@ -6,6 +6,7 @@ import test from "node:test"; import { digestComposedJson } from "../compose/json.js"; import { parseTargetResourceReceipt, + isTargetPublicArtifactNotPresent, readTargetPublicBytes, readTargetPublicJson, verifyTargetWorldClockReceipt, @@ -169,6 +170,29 @@ test("public-artifact byte reader returns only exactly correlated verified bytes }), /correlation is invalid/u); }); +test("public-artifact pending classifier admits only the exact correlated receipt", () => { + const pending = { + artifact_id: "viewer_trace", + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", publicArtifactRequest, + ), + run_id: publicArtifactRequest.run_id, + status: "not_present", + version: "spawnfile.target-public-artifact-snapshot.not-present.v1", + }; + assert.equal(isTargetPublicArtifactNotPresent({ + artifact_id: "viewer_trace", raw: pending, request: publicArtifactRequest, + }), true); + for (const forged of [ + { ...pending, run_id: "other-run" }, + { ...pending, request_digest: d("f") }, + { ...pending, extra: true }, + { ...pending, version: "spawnfile.target-public-artifact-snapshot.not-present.v2" }, + ]) assert.equal(isTargetPublicArtifactNotPresent({ + artifact_id: "viewer_trace", raw: forged, request: publicArtifactRequest, + }), false); +}); + test("public JSON reader delegates verification and zeroes decoded bytes after parsing", () => { const requestValue = { ...publicArtifactRequest, diff --git a/src/spawnfile/targetReceipts.ts b/src/spawnfile/targetReceipts.ts index bd0e10f..adafb53 100644 --- a/src/spawnfile/targetReceipts.ts +++ b/src/spawnfile/targetReceipts.ts @@ -1,251 +1,16 @@ -import { Buffer } from "node:buffer"; -import { createHash } from "node:crypto"; - -import { z } from "zod"; - -import { assertSecretFreeComposedJson, digestComposedJson } from "../compose/json.js"; - -const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); -const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); -const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); -export const targetSelectedTargetSchema = z.object({ - fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), - handle, -}).strict(); - -const targetEvidenceIndexSchema = z.object({ - evidence_digest: digest, - export_handle: handle, - files: z.array(z.object({ - bytes: z.number().int().min(0).max(67_108_864), - path: z.string().max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u) - .or(z.literal(".spawnfile/world-service-activated.v1")) - .refine((value) => !value.includes("//") - && !value.split("/").some((part) => part === "." || part === "..")), - sha256: digest, - }).strict()).max(10_000), - item_count: z.number().int().min(0).max(10_000), - labels: z.array(z.object({ - key: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), - value: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), - }).strict()).max(16), - run_id: runId, - source: z.object({ evidence_volume_handle: handle, state: z.literal("preserved") }).strict(), - state: z.literal("exported"), - version: z.literal("spawnfile.target-resource.export-index.v1"), -}).strict().superRefine((value, context) => { - const paths = value.files.map(({ path }) => path); - if (value.item_count !== value.files.length || new Set(paths).size !== paths.length - || paths.some((entry, index) => index > 0 && paths[index - 1]! >= entry)) context.addIssue({ - code: z.ZodIssueCode.custom, message: "target evidence inventory is invalid", - }); -}); - -export const targetResourceReceiptSchema = z.object({ - cleanup_state: z.enum(["not_requested", "preserved", "removed", "incomplete"]).nullable(), - descriptor_digest: digest, - evidence_index: targetEvidenceIndexSchema.optional(), - export_state: z.enum(["not_requested", "exported", "incomplete"]).nullable(), - labels: z.array(z.object({ - key: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), - value: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), - }).strict()).max(16), - operation: z.enum([ - "attach_organization", "cleanup_run", "create_world_service", "detach_organization", - "export_evidence_volume", "revoke_secret_bindings", "start_world_service", - "stop_world_service", - ]), - operation_handle: handle, - receipt_digest: digest, - request_digest: digest, - result_handle: handle.nullable(), - resulting_revision: z.number().int().min(1).max(2_147_483_647), - run_id: runId, - selected_target: targetSelectedTargetSchema, - version: z.literal("spawnfile.target-resource.receipt.v1"), -}).strict().superRefine((value, context) => { - if ((value.operation === "export_evidence_volume") !== (value.evidence_index !== undefined)) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "target evidence index is invalid" }); - } - if (value.evidence_index !== undefined && (value.export_state !== "exported" - || value.result_handle !== value.evidence_index.export_handle - || value.run_id !== value.evidence_index.run_id - || JSON.stringify(value.labels) !== JSON.stringify(value.evidence_index.labels))) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "target evidence receipt is invalid" }); - } -}); - -export type TargetResourceReceipt = z.infer; - -export const parseTargetResourceReceipt = (raw: unknown): TargetResourceReceipt => { - assertSecretFreeComposedJson(raw); - const receipt = targetResourceReceiptSchema.parse(raw); - const { receipt_digest: _receiptDigest, ...body } = receipt; - if (receipt.receipt_digest !== digestComposedJson("spawnfile.target-resource.receipt.v1", body)) { - throw new TypeError("Spawnfile target receipt digest is invalid"); - } - return Object.freeze(receipt); -}; - -export const verifyTargetResourceReceipt = (input: Readonly<{ - operation: TargetResourceReceipt["operation"]; - raw: unknown; - request: Readonly>; - resulting_revision: number; - run_id: string; -}>): TargetResourceReceipt => { - const receipt = parseTargetResourceReceipt(input.raw); - const expectedTarget = input.request.selected_target; - if (receipt.operation !== input.operation - || receipt.run_id !== input.run_id - || receipt.run_id !== input.request.run_id - || receipt.descriptor_digest !== input.request.descriptor_digest - || JSON.stringify(receipt.selected_target) !== JSON.stringify(expectedTarget) - || receipt.resulting_revision !== input.resulting_revision - || receipt.request_digest !== digestComposedJson( - "spawnfile.target-resource.request.v1", input.request, - )) throw new TypeError("Spawnfile target operation correlation is invalid"); - return receipt; -}; - -const readinessReceipt = z.object({ - readiness: z.unknown(), - readiness_digest: digest, - request_digest: digest, - run_id: runId, - version: z.literal("spawnfile.target-world-readiness-receipt.v1"), -}).strict(); - -export const verifyTargetReadinessReceipt = (input: Readonly<{ - raw: unknown; - request: Readonly>; -}>): unknown => { - assertSecretFreeComposedJson(input.raw); - const receipt = readinessReceipt.parse(input.raw); - if (receipt.run_id !== input.request.run_id - || receipt.request_digest !== digestComposedJson( - "spawnfile.target-world-readiness.request.v1", input.request, - ) - || receipt.readiness_digest !== digestComposedJson( - "spawnfile.target-world-readiness.document.v1", receipt.readiness, - )) throw new TypeError("Spawnfile target readiness correlation is invalid"); - return receipt.readiness; -}; - -const worldClockReceipt = z.object({ - action_count: z.literal(0), - activation_digest: digest, - activation_receipt_digest: digest, - clock: z.object({ - completed_tick: z.number().int().min(1).max(1_000_000_000), - next_tick: z.number().int().min(2).max(1_000_000_001), - state: z.literal("running"), - }).strict(), - observation_digest: digest, - receipt_digest: digest, - request_digest: digest, - run_id: runId, - topology_receipt_digest: digest, - topology_request_digest: digest, - version: z.literal("spawnfile.target-world-clock-receipt.v1"), - world_instance_id: runId, - world_service_handle: handle, -}).strict().superRefine((value, context) => { - if (value.clock.next_tick !== value.clock.completed_tick + 1) context.addIssue({ - code: z.ZodIssueCode.custom, message: "world clock frontier is invalid", - }); -}); - -export const verifyTargetWorldClockReceipt = (input: Readonly<{ - raw: unknown; - request: Readonly>; -}>): z.infer => { - assertSecretFreeComposedJson(input.raw); - const receipt = worldClockReceipt.parse(input.raw); - const expected = input.request.expected as Readonly>; - const { receipt_digest: _receiptDigest, ...body } = receipt; - const observation = { - action_count: receipt.action_count, - clock: receipt.clock, - run_id: receipt.run_id, - version: expected.document_version, - world_instance_id: receipt.world_instance_id, - }; - if (receipt.run_id !== input.request.run_id - || receipt.world_service_handle !== input.request.world_service_handle - || receipt.world_instance_id !== expected.world_instance_id - || receipt.activation_digest !== input.request.activation_digest - || receipt.activation_receipt_digest !== input.request.activation_receipt_digest - || receipt.topology_receipt_digest !== input.request.topology_receipt_digest - || receipt.topology_request_digest !== input.request.topology_request_digest - || receipt.request_digest !== digestComposedJson( - "spawnfile.target-world-clock.request.v1", input.request, - ) - || receipt.observation_digest !== digestComposedJson( - "spawnfile.target-world-clock.observation.v1", observation, - ) - || receipt.receipt_digest !== digestComposedJson( - "spawnfile.target-world-clock-receipt.v1", body, - )) throw new TypeError("Spawnfile target world clock correlation is invalid"); - return receipt; -}; - -const publicArtifact = z.object({ - artifact_id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), - content_base64: z.string().max(Math.ceil(131_072 / 3) * 4), - content_digest: digest, - media_type: z.string().min(1).max(127), - request_digest: digest, - run_id: runId, - size_bytes: z.number().int().min(0).max(131_072), - version: z.literal("spawnfile.target-public-artifact-snapshot.v1"), -}).strict(); - -interface TargetPublicArtifactReadInput { - artifact_id: string; - raw: unknown; - request: Readonly>; -} - -/** Returns a fresh verified buffer; the caller owns and should zero it after use. */ -export const readTargetPublicBytes = ( - input: Readonly, -): Buffer => { - assertSecretFreeComposedJson(input.raw); - const receipt = publicArtifact.parse(input.raw); - const bytes = Buffer.from(receipt.content_base64, "base64"); - try { - const requestedArtifact = input.request.artifact as Readonly<{ - id?: unknown; - max_bytes?: unknown; - media_type?: unknown; - }> | undefined; - if (bytes.toString("base64") !== receipt.content_base64 - || bytes.byteLength !== receipt.size_bytes - || receipt.artifact_id !== input.artifact_id - || receipt.artifact_id !== requestedArtifact?.id - || receipt.run_id !== input.request.run_id - || receipt.media_type !== requestedArtifact?.media_type - || !Number.isSafeInteger(requestedArtifact?.max_bytes) - || receipt.size_bytes > (requestedArtifact?.max_bytes as number) - || receipt.content_digest !== `sha256:${createHash("sha256").update(bytes).digest("hex")}` - || receipt.request_digest !== digestComposedJson( - "spawnfile.target-public-artifact-snapshot.request.v1", input.request, - )) throw new TypeError("Spawnfile public artifact correlation is invalid"); - return bytes; - } catch (error) { - bytes.fill(0); - throw error; - } -}; - -export const readTargetPublicJson = ( - input: Readonly, -): unknown => { - const bytes = readTargetPublicBytes(input); - try { - return JSON.parse(bytes.toString("utf8")) as unknown; - } finally { - bytes.fill(0); - } -}; +export { + parseTargetResourceReceipt, + targetResourceReceiptSchema, + targetSelectedTargetSchema, + verifyTargetResourceReceipt, + type TargetResourceReceipt, +} from "./targetResourceReceipts.js"; +export { + verifyTargetReadinessReceipt, + verifyTargetWorldClockReceipt, +} from "./targetWorldReceipts.js"; +export { + isTargetPublicArtifactNotPresent, + readTargetPublicBytes, + readTargetPublicJson, +} from "./targetPublicArtifact.js"; diff --git a/src/spawnfile/targetResourceReceipts.ts b/src/spawnfile/targetResourceReceipts.ts new file mode 100644 index 0000000..4b2f60c --- /dev/null +++ b/src/spawnfile/targetResourceReceipts.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +export const targetSelectedTargetSchema = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), handle, +}).strict(); +const label = z.object({ key: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u), + value: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u) }).strict(); +const evidenceIndex = z.object({ + evidence_digest: digest, + export_handle: handle, + files: z.array(z.object({ + bytes: z.number().int().min(0).max(67_108_864), + path: z.string().max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u) + .or(z.literal(".spawnfile/world-service-activated.v1")) + .refine((value) => !value.includes("//") + && !value.split("/").some((part) => part === "." || part === "..")), + sha256: digest, + }).strict()).max(10_000), + item_count: z.number().int().min(0).max(10_000), + labels: z.array(label).max(16), + run_id: runId, + source: z.object({ evidence_volume_handle: handle, + state: z.literal("preserved") }).strict(), + state: z.literal("exported"), + version: z.literal("spawnfile.target-resource.export-index.v1"), +}).strict().superRefine((value, context) => { + const paths = value.files.map(({ path }) => path); + if (value.item_count !== value.files.length || new Set(paths).size !== paths.length + || paths.some((entry, index) => index > 0 && paths[index - 1]! >= entry)) { + context.addIssue({ code: z.ZodIssueCode.custom, + message: "target evidence inventory is invalid" }); + } +}); + +export const targetResourceReceiptSchema = z.object({ + cleanup_state: z.enum(["not_requested", "preserved", "removed", "incomplete"]).nullable(), + descriptor_digest: digest, + evidence_index: evidenceIndex.optional(), + export_state: z.enum(["not_requested", "exported", "incomplete"]).nullable(), + labels: z.array(label).max(16), + operation: z.enum(["attach_organization", "cleanup_run", "create_world_service", + "detach_organization", "export_evidence_volume", "revoke_secret_bindings", + "start_world_service", "stop_world_service"]), + operation_handle: handle, receipt_digest: digest, request_digest: digest, + result_handle: handle.nullable(), + resulting_revision: z.number().int().min(1).max(2_147_483_647), + run_id: runId, selected_target: targetSelectedTargetSchema, + version: z.literal("spawnfile.target-resource.receipt.v1"), +}).strict().superRefine((value, context) => { + if ((value.operation === "export_evidence_volume") !== (value.evidence_index !== undefined) + || value.evidence_index !== undefined && (value.export_state !== "exported" + || value.result_handle !== value.evidence_index.export_handle + || value.run_id !== value.evidence_index.run_id + || JSON.stringify(value.labels) !== JSON.stringify(value.evidence_index.labels))) { + context.addIssue({ code: z.ZodIssueCode.custom, + message: "target evidence receipt is invalid" }); + } +}); + +export type TargetResourceReceipt = z.infer; + +export const parseTargetResourceReceipt = (raw: unknown): TargetResourceReceipt => { + assertSecretFreeComposedJson(raw); + const receipt = targetResourceReceiptSchema.parse(raw); + const { receipt_digest: _digest, ...body } = receipt; + if (receipt.receipt_digest !== digestComposedJson( + "spawnfile.target-resource.receipt.v1", body, + )) throw new TypeError("Spawnfile target receipt digest is invalid"); + return Object.freeze(receipt); +}; + +export const verifyTargetResourceReceipt = (input: Readonly<{ + operation: TargetResourceReceipt["operation"]; + raw: unknown; + request: Readonly>; + resulting_revision: number; + run_id: string; +}>): TargetResourceReceipt => { + const receipt = parseTargetResourceReceipt(input.raw); + if (receipt.operation !== input.operation || receipt.run_id !== input.run_id + || receipt.run_id !== input.request.run_id + || receipt.descriptor_digest !== input.request.descriptor_digest + || JSON.stringify(receipt.selected_target) !== JSON.stringify(input.request.selected_target) + || receipt.resulting_revision !== input.resulting_revision + || receipt.request_digest !== digestComposedJson( + "spawnfile.target-resource.request.v1", input.request, + )) throw new TypeError("Spawnfile target operation correlation is invalid"); + return receipt; +}; diff --git a/src/spawnfile/targetSelection.ts b/src/spawnfile/targetSelection.ts new file mode 100644 index 0000000..5bbb215 --- /dev/null +++ b/src/spawnfile/targetSelection.ts @@ -0,0 +1,43 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { z } from "zod"; + +import { canonicalComposedJson } from "../compose/json.js"; +import { runSpawnfileProcess, type BootstrapSpawnfileCliContext } from "./process.js"; + +const selectedTarget = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), + handle: z.string().regex(/^opaque_[a-z0-9]{16,64}$/u), + version: z.literal("spawnfile.target-resource.selected-target.v1"), +}).strict(); +export type SpawnfileSelectedTarget = z.infer; +export const parseSpawnfileSelectedTarget = (raw: unknown): SpawnfileSelectedTarget => + Object.freeze(selectedTarget.parse(raw)); + +export const runSpawnfileSelectTarget = async (input: Readonly<{ + context: BootstrapSpawnfileCliContext; + request: Readonly>; + signal?: AbortSignal; + target_config: Uint8Array; +}>): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-target-select-")); + const config = Uint8Array.from(input.target_config); + if (config.byteLength < 1 || config.byteLength > 262_144) { + throw new TypeError("Spawnfile target configuration is invalid"); + } + try { + const request = path.join(root, "request.json"); + await writeFile(request, canonicalComposedJson(input.request), { mode: 0o600 }); + const result = await runSpawnfileProcess(input.context, { + args: ["target", "--config", "-", "select_target", request], + signal: input.signal, + stdin: config, + }); + return parseSpawnfileSelectedTarget(JSON.parse(result.stdout) as unknown); + } finally { + config.fill(0); + await rm(root, { force: true, recursive: true }); + } +}; diff --git a/src/spawnfile/targetWorldReceipts.ts b/src/spawnfile/targetWorldReceipts.ts new file mode 100644 index 0000000..100d322 --- /dev/null +++ b/src/spawnfile/targetWorldReceipts.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const readinessReceipt = z.object({ readiness: z.unknown(), readiness_digest: digest, + request_digest: digest, run_id: runId, + version: z.literal("spawnfile.target-world-readiness-receipt.v1") }).strict(); + +export const verifyTargetReadinessReceipt = (input: Readonly<{ + raw: unknown; + request: Readonly>; +}>): unknown => { + assertSecretFreeComposedJson(input.raw); + const receipt = readinessReceipt.parse(input.raw); + if (receipt.run_id !== input.request.run_id + || receipt.request_digest !== digestComposedJson( + "spawnfile.target-world-readiness.request.v1", input.request, + ) || receipt.readiness_digest !== digestComposedJson( + "spawnfile.target-world-readiness.document.v1", receipt.readiness, + )) throw new TypeError("Spawnfile target readiness correlation is invalid"); + return receipt.readiness; +}; + +const worldClockReceipt = z.object({ + action_count: z.literal(0), activation_digest: digest, + activation_receipt_digest: digest, + clock: z.object({ completed_tick: z.number().int().min(1).max(1_000_000_000), + next_tick: z.number().int().min(2).max(1_000_000_001), + state: z.literal("running") }).strict(), + observation_digest: digest, receipt_digest: digest, request_digest: digest, + run_id: runId, topology_receipt_digest: digest, topology_request_digest: digest, + version: z.literal("spawnfile.target-world-clock-receipt.v1"), + world_instance_id: runId, world_service_handle: handle, +}).strict().superRefine((value, context) => { + if (value.clock.next_tick !== value.clock.completed_tick + 1) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "world clock frontier is invalid" }); + } +}); + +export const verifyTargetWorldClockReceipt = (input: Readonly<{ + raw: unknown; + request: Readonly>; +}>): z.infer => { + assertSecretFreeComposedJson(input.raw); + const receipt = worldClockReceipt.parse(input.raw); + const expected = input.request.expected as Readonly>; + const { receipt_digest: _digest, ...body } = receipt; + const observation = { action_count: receipt.action_count, clock: receipt.clock, + run_id: receipt.run_id, version: expected.document_version, + world_instance_id: receipt.world_instance_id }; + if (receipt.run_id !== input.request.run_id + || receipt.world_service_handle !== input.request.world_service_handle + || receipt.world_instance_id !== expected.world_instance_id + || receipt.activation_digest !== input.request.activation_digest + || receipt.activation_receipt_digest !== input.request.activation_receipt_digest + || receipt.topology_receipt_digest !== input.request.topology_receipt_digest + || receipt.topology_request_digest !== input.request.topology_request_digest + || receipt.request_digest !== digestComposedJson( + "spawnfile.target-world-clock.request.v1", input.request, + ) || receipt.observation_digest !== digestComposedJson( + "spawnfile.target-world-clock.observation.v1", observation, + ) || receipt.receipt_digest !== digestComposedJson( + "spawnfile.target-world-clock-receipt.v1", body, + )) throw new TypeError("Spawnfile target world clock correlation is invalid"); + return receipt; +}; diff --git a/src/test-support/AGENTS.md b/src/test-support/AGENTS.md new file mode 100644 index 0000000..df3d7c3 --- /dev/null +++ b/src/test-support/AGENTS.md @@ -0,0 +1,9 @@ +# Public Test-Support Guide + +This folder contains intentionally exported, narrow testing authorities. + +- Exports must remain generic and must not encode a fixture, local checkout, + target, credential, or service lifecycle. +- Keep helpers deterministic and bounded; every port, poll, and controller + must have an explicit owner and terminal condition. +- Production modules must not import this folder. diff --git a/src/test-support/CLAUDE.md b/src/test-support/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/test-support/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/view/AGENTS.md b/src/view/AGENTS.md index 95b8cde..d1b30a8 100644 --- a/src/view/AGENTS.md +++ b/src/view/AGENTS.md @@ -3,9 +3,8 @@ This folder contains the `simfile view` implementation: CLI argument parsing, the static/JSON/SSE server for the world (GlyphCSS) replay and live modes, and an additive **run-replay mode** for compose-and-observe run directories -(`manifest.json` @ `simfile.run-manifest.v1` + `raw/moltnet/transcript.json` -— the shape in `fixtures/observe/office-sim-golden/`, and a real engine run, -e.g. `runs/real-grok-composed/`). Run-replay mode serves the same React +(`manifest.json` @ `simfile.run-manifest.v1`; transcripts are optional +artifacts, not a routing precondition). Run-replay mode serves the same React shell as world/live mode (`web/src/viewer/RunReplayShell.tsx`), fed by `/api/timeline` and a `viewer.trace.v1`-shaped `/api/world` adapter, so the existing time-scrubbable map/portal machinery renders a real run instead of diff --git a/src/world-artifact/AGENTS.md b/src/world-artifact/AGENTS.md index 0bfd6e5..0a56b9c 100644 --- a/src/world-artifact/AGENTS.md +++ b/src/world-artifact/AGENTS.md @@ -16,12 +16,14 @@ generic Simfile world service. it never starts services or resolves deployment targets. - `preparedBundleCache.ts` hashes declared source/config inputs and validates every byte of a cached runnable bundle before reuse. -- The artifact consumes the frozen Tiny Football production descriptor only to - assert generic contract identity; it contains no fixture behavior. +- Artifact contract assertions must use genre-neutral inputs and contain no + fixture behavior or unavailable external descriptor. - `readiness.ts` owns the strict, secret-free, paused-world projection exposed to a public Spawnfile query. It cannot represent organization readiness. - `clockObservation.ts` owns the strict post-activation world-clock projection; it reports observed progress and action count without advancing the clock. +- `terminalSignal.ts` owns the public, canonical world terminal signal and its + fixed Spawnfile-readable artifact location for composed authoring. - `entrypoint.ts` is the thin public composition surface for the runnable sidecar entrypoint. - `worldServiceConstruction.ts` builds the bound generic world service from a diff --git a/src/world-artifact/composedDevelopmentExample.test.ts b/src/world-artifact/composedDevelopmentExample.test.ts new file mode 100644 index 0000000..057d02f --- /dev/null +++ b/src/world-artifact/composedDevelopmentExample.test.ts @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const exampleRoot = path.join( + packageRoot, + "examples", + "composed-development", +); +const simfilePath = path.join(exampleRoot, "Simfile"); +const spawnfilePath = path.join(exampleRoot, "org", "Spawnfile"); +const digest = (character: string): `sha256:${string}` => + `sha256:${character.repeat(64)}`; + +const extractBundle = async (bytes: readonly number[], destination: string): Promise => { + const archive = Uint8Array.from(bytes); + let offset = 0; + const admitted = new Set([ + "bundle.json", "world-artifact/composer.mjs", "world-artifact/entrypoint.mjs", + "world-artifact/manifest.json", "world-artifact/provider.mjs", + "world-artifact/runner.mjs", + ]); + while (offset + 512 <= archive.byteLength && archive[offset] !== 0) { + const header = archive.subarray(offset, offset + 512); + const nameEnd = header.indexOf(0); + const name = new TextDecoder().decode(header.subarray(0, nameEnd)); + const rawSize = new TextDecoder().decode(header.subarray(124, 136)) + .replace(/\0.*$/u, "").trim(); + const size = Number.parseInt(rawSize, 8); + assert.equal(admitted.delete(name), true, name); + assert.equal(header[156], 0x30, name); + assert.equal(Number.isSafeInteger(size) && size >= 0, true, name); + const start = offset + 512; + const target = path.join(destination, name); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, archive.subarray(start, start + size), { flag: "wx" }); + offset = start + Math.ceil(size / 512) * 512; + } + assert.equal(admitted.size, 0); + assert.equal(offset + 1024, archive.byteLength); +}; + +const readEventually = async (file: string): Promise => { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { return await readFile(file); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await delay(2); + } + throw new Error(`timed out waiting for ${path.basename(file)}`); +}; + +const principalResolver = Object.freeze({ + resolveParticipant: (principal: string) => + principal === "agent:smoke" ? "smoke" : undefined, + resolvePrincipal: (participant: string) => + participant === "smoke" ? "agent:smoke" : undefined, +}); + +const initialCheckpoint = async (runId: string, seed: string) => { + const [dynamics, schema, world, worldArtifact, worldSurface] = await Promise.all([ + import(["simfile", "dynamics"].join("/")) as Promise, + import(["simfile", "schema"].join("/")) as Promise, + import(["simfile", "world"].join("/")) as Promise, + import(["simfile", "world-artifact"].join("/")) as Promise, + import(["simfile", "world-surface"].join("/")) as Promise, + ]); + const parsed = schema.parseSimfileSource(await readFile(simfilePath, "utf8"), { + path: simfilePath, + }); + assert.ok(parsed.simfile.world); + const session = await dynamics.loadDynamicsSession(parsed.simfile, { + seed, + simfilePath, + }); + assert.ok(session); + const surfaceModule = await import(pathToFileURL( + path.join(exampleRoot, "world", "surface.mjs"), + ).href) as { createWorldSurfaceDefinition(): unknown }; + const runtime = world.createWorldRuntime(world.composeWorldRuntimeInput({ + principalResolver, + runId, + session, + surfaceRegistry: worldSurface.parseWorldSurfaceDefinition( + surfaceModule.createWorldSurfaceDefinition(), + ), + world: parsed.simfile.world, + worldInstanceId: "composed-development-world", + })); + return worldArtifact.captureWorldCheckpoint(runtime); +}; + +test("standalone composed-development example builds and prepares exact contracts", async () => { + await ensurePublicPackageBuild(packageRoot); + const bindingModule = await import(`${pathToFileURL( + path.join(exampleRoot, "binding.mjs"), + ).href}?contract=${Date.now()}`) as { + composedProjectBinding: { + prepareComposedProject(input: unknown): Promise; + version: string; + }; + }; + assert.equal( + bindingModule.composedProjectBinding.version, + "simfile.composed-project-binding.v1", + ); + const runId = "composed-development-contract"; + const seed = "composed-development-contract-seed"; + const preparation = await bindingModule.composedProjectBinding.prepareComposedProject({ + base_image_config_digest: digest("a"), + evidence_root: "/var/lib/simfile/evidence", + internal_port: 4070, + organization_container_name: "composed-development", + platform: { + architecture: process.arch === "arm64" ? "arm64" : "amd64", + os: "linux", + }, + run_id: runId, + secret_root: "/run/spawnfile-secrets", + seed, + simfile_path: simfilePath, + spawnfile_path: spawnfilePath, + }); + + assert.equal(preparation.bundle.manifest.digest, + preparation.readiness_expectation.bundle_digest); + assert.equal(preparation.bundle.manifest.artifact.service_digest, + preparation.readiness_expectation.artifact_digest); + assert.deepEqual(preparation.bundle.manifest.composer.provenance.source_graph.map( + ({ path: sourcePath }: { path: string }) => sourcePath, + ), [ + "examples/composed-development/world/composer.mjs", + "examples/composed-development/world/evidence.mjs", + "examples/composed-development/world/surface.mjs", + ]); + assert.deepEqual(preparation.credentials, [{ + bytes: 32, + env: "SIMFILE_WORLD_TOKEN", + kind: "generated-token", + name: "world_token", + }]); + assert.deepEqual(preparation.secret_bindings, [{ + credential_name: "world_token", + name: "world_token", + scope: "world", + }]); + assert.equal(preparation.world_members.length, 1); + assert.equal(preparation.world_members[0].id, "smoke"); + assert.equal(preparation.world_members[0].principal_id, "agent:smoke"); + assert.deepEqual(preparation.evidence_artifacts, [ + { path: "actions/accepted.json", role: "accepted-action", source: "actions/accepted-strategic-actions.json" }, + { path: "actions/results.jsonl", role: "action-result", source: "actions/results.jsonl" }, + { path: "identity/principals.json", role: "identity", source: "projections/principals.json" }, + { path: "probes/lifecycle-replay.json", role: "probe", source: "projections/lifecycle-replay-probe.json" }, + { path: "replay/accepted-actions.jsonl", role: "accepted-action", source: "actions/replay-accepted-actions.jsonl" }, + { path: "replay/expected.json", role: "terminal", source: "projections/replay-expected.json" }, + { path: "replay/initial-checkpoint.json", role: "world-checkpoint", source: "checkpoints/initial.json" }, + { path: "replay/terminal-checkpoint.json", role: "world-checkpoint", source: "checkpoints/terminal.json" }, + { path: "world/frames.jsonl", role: "world-frame", source: "projections/frames.jsonl" }, + { path: "world/terminal-state.json", role: "provenance", source: "projections/terminal-state.json" }, + ]); + + const checkpoint = await initialCheckpoint(runId, seed); + const publicWorldArtifact = await import( + ["simfile", "world-artifact"].join("/") + ) as typeof import("./index.js"); + const identity = publicWorldArtifact.worldReadinessIdentity(checkpoint); + const hashes = publicWorldArtifact.worldReadinessHashes(checkpoint); + assert.equal(preparation.readiness_expectation.run_id, identity.run_id); + assert.equal(preparation.readiness_expectation.world_instance_id, + identity.world_instance_id); + assert.deepEqual(preparation.readiness_expectation.capability_manifest_digests, + identity.capability_manifest_digests); + assert.equal(preparation.readiness_expectation.mechanics_sha256, hashes.mechanics); + assert.equal(preparation.readiness_expectation.normalized_checkpoint_sha256, + hashes.normalized_checkpoint); + assert.doesNotThrow(() => publicWorldArtifact.verifyWorldSidecarReadiness({ + ...preparation.readiness_expectation, + clock: { next_tick: 0, state: "paused" }, + decisions: { count: 0, phase: "open" }, + runtime_abi: preparation.bundle.manifest.runtime_abi, + status: "ready", + version: "simfile.world-sidecar-readiness.v1", + }, preparation.readiness_expectation)); + + const replayState = await preparation.replay_adapter.restore(checkpoint); + const replay = await preparation.replay_adapter.finish(replayState); + assert.equal(replay.terminal_tick, 4); + const terminal = JSON.parse(new TextDecoder().decode(replay.terminal_state)) as { + dynamics: { next_tick: number; provider_state: { value: number } }; + version: string; + }; + assert.equal(terminal.version, + "simfile.composed-lifecycle-replay-terminal.v1"); + assert.equal(terminal.dynamics.next_tick, 4); + assert.equal(terminal.dynamics.provider_state.value, 4); + const probe = JSON.parse(new TextDecoder().decode(replay.probe)) as { + live_agent_action: string; + passed: boolean; + }; + assert.deepEqual(probe, { + live_agent_action: "not_evaluated", + passed: true, + run_id: runId, + terminal_tick: 4, + version: "simfile.composed-lifecycle-replay-smoke.v1", + }); + await assert.rejects(Promise.resolve().then(() => + preparation.replay_adapter.inject({ + action: {}, boundary_tick: 0, ordinal: 0, state: replayState, + })), /accepts no recorded actions/u); +}); + +test("composed-development example has no checkout, private-source, or machine spillover", async () => { + const files = [ + "binding.mjs", + "harness/scripted-engine.mjs", + "world/composer.mjs", + "world/evidence.mjs", + "world/provider.mjs", + "world/surface.mjs", + ]; + for (const relative of files) { + const source = await readFile(path.join(exampleRoot, relative), "utf8"); + assert.doesNotMatch(source, + /(?:\/Users\/[^/]+\/|\/home\/[^/]+\/|[A-Za-z]:\\Users\\[^\\]+\\|gpu[-_ ]?[0-9]{3,5}|\/src\/|\.\.\/spawnfile)/iu, + relative); + for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/gu)) { + const specifier = match[1]!; + assert.ok(specifier.startsWith("node:") + || specifier.startsWith("simfile/") + || specifier.startsWith("./"), `${relative}: ${specifier}`); + if (relative === "world/composer.mjs") { + assert.ok(specifier.startsWith("./"), `${relative}: ${specifier}`); + } + } + } + const spawnfile = await readFile(spawnfilePath, "utf8"); + const agent = await readFile( + path.join(exampleRoot, "org", "agents", "smoke", "Spawnfile"), + "utf8", + ); + assert.doesNotMatch(`${spawnfile}\n${agent}`, /(?:networks:|surfaces:|auth:)/u); + for (const link of [ + "CLAUDE.md", + "harness/CLAUDE.md", + "org/CLAUDE.md", + "org/agents/smoke/CLAUDE.md", + "world/CLAUDE.md", + ]) { + assert.equal((await lstat(path.join(exampleRoot, link))).isSymbolicLink(), true, link); + } +}); + +test("emitted example controller writes evidence and atomically publishes terminal truth", async () => { + await ensurePublicPackageBuild(packageRoot); + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-example-live-")); + const evidenceRoot = path.join(root, "evidence"); + const bundleRoot = path.join(root, "bundle"); + const runId = "composed-development-controller"; + let controller: { close(): Promise } | undefined; + const publicWorldArtifact = await import( + ["simfile", "world-artifact"].join("/") + ) as typeof import("./index.js"); + const terminalPath = publicWorldArtifact.COMPOSED_WORLD_TERMINAL_ARTIFACT.path; + try { + await assert.rejects(lstat(terminalPath), (error: NodeJS.ErrnoException) => + error.code === "ENOENT"); + const binding = await import(`${pathToFileURL( + path.join(exampleRoot, "binding.mjs"), + ).href}?controller=${Date.now()}`) as { + composedProjectBinding: { prepareComposedProject(input: unknown): Promise }; + }; + const preparation = await binding.composedProjectBinding.prepareComposedProject({ + base_image_config_digest: digest("a"), + evidence_root: evidenceRoot, + internal_port: 4070, + organization_container_name: "composed-development-controller", + platform: { architecture: process.arch === "arm64" ? "arm64" : "amd64", os: "linux" }, + run_id: runId, + secret_root: path.join(root, "secrets"), + seed: "composed-development-controller-seed", + simfile_path: simfilePath, + spawnfile_path: spawnfilePath, + }); + await extractBundle(preparation.bundle.archive_bytes, bundleRoot); + const composer = await import(`${pathToFileURL( + path.join(bundleRoot, "world-artifact", "composer.mjs"), + ).href}?run=${Date.now()}`) as { + composeWorldRuntime(): any; + proveWorldRuntimeReadiness(runtime: unknown): void; + startWorldRuntime(runtime: unknown, activation: unknown): { close(): Promise }; + }; + const emitted = await import(pathToFileURL( + path.join(bundleRoot, "world-artifact", "entrypoint.mjs"), + ).href) as typeof import("./entrypoint.js"); + const runtime = emitted.createWorldRuntime(composer.composeWorldRuntime()); + composer.proveWorldRuntimeReadiness(runtime); + controller = composer.startWorldRuntime(runtime, { ready: Promise.resolve() }); + const terminalBytes = await readEventually(terminalPath); + const signal = publicWorldArtifact.parseComposedWorldTerminalSignal( + JSON.parse(new TextDecoder().decode(terminalBytes)), + ); + assert.equal(signal.run_id, runId); + assert.equal(signal.terminal_tick, 4); + const terminalState = await readFile( + path.join(evidenceRoot, "projections", "terminal-state.json"), + ); + assert.equal(signal.outcome_digest, + `sha256:${createHash("sha256").update(terminalState).digest("hex")}`); + const world = await import( + ["simfile", "world"].join("/") + ) as typeof import("../world/index.js"); + const terminalCheckpoint = world.parseWorldCheckpoint(JSON.parse(await readFile( + path.join(evidenceRoot, "checkpoints", "terminal.json"), "utf8", + )) as unknown); + assert.ok(terminalCheckpoint); + assert.equal(terminalCheckpoint.dynamics.next_tick, 4); + for (const artifact of preparation.evidence_artifacts) { + assert.ok((await readFile(path.join(evidenceRoot, artifact.source))).byteLength >= 0, + artifact.source); + } + const accepted = JSON.parse(await readFile( + path.join(evidenceRoot, "actions", "accepted-strategic-actions.json"), "utf8", + )) as { actions: unknown[] }; + assert.deepEqual(accepted.actions, []); + await controller.close(); + controller = undefined; + } finally { + await controller?.close().catch(() => undefined); + const ownedTerminal = await readFile(terminalPath, "utf8").then((source) => { + try { + return publicWorldArtifact.parseComposedWorldTerminalSignal( + JSON.parse(source), + ).run_id === runId; + } catch { return false; } + }).catch(() => false); + if (ownedTerminal) await unlink(terminalPath); + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/world-artifact/entrypoint.ts b/src/world-artifact/entrypoint.ts index ff98111..c19b49f 100644 --- a/src/world-artifact/entrypoint.ts +++ b/src/world-artifact/entrypoint.ts @@ -12,6 +12,7 @@ export { createDecisionRegistry } from "../world/decisionRegistry.js"; export { bindWorldGrants } from "../world/grants.js"; export { createWorldReadLedger } from "../world/ledger.js"; export { composeWorldRuntimeInput } from "../world/runtimeComposition.js"; +export { createWorldRuntime } from "../world/runtime.js"; export { WORLD_DECISION_CLAIM_CAPABILITY } from "../world/decisionClaim.js"; export { readWorldRuntimeClockAuthority } from "../world/clockAuthority.js"; export type { WorldDynamicsTickRecord } from "../world/clockAuthority.js"; @@ -24,6 +25,11 @@ export type { CausalRecorder } from "../runtime/causalRecording.js"; export { createMoltnetMachineClient } from "../moltnet/machine/client.js"; export type { ResolvedWorldGrant } from "../world/grants.js"; export type { CreateWorldRuntimeInput, WorldRuntime } from "../world/runtime.js"; +export { + COMPOSED_WORLD_TERMINAL_ARTIFACT, + createComposedWorldTerminalSignal, + publishComposedWorldTerminalSignal, +} from "./terminalSignal.js"; export { startWorldServiceSidecar } from "./sidecarEntrypoint.js"; export { diff --git a/src/world-artifact/index.ts b/src/world-artifact/index.ts index 4baff81..8f0e882 100644 --- a/src/world-artifact/index.ts +++ b/src/world-artifact/index.ts @@ -105,3 +105,13 @@ export { type WorldReadinessHashes, type WorldReadinessIdentity, } from "./sidecarReadiness.js"; +export { + COMPOSED_WORLD_TERMINAL_ARTIFACT, + COMPOSED_WORLD_TERMINAL_SIGNAL_VERSION, + composedWorldTerminalSignalSchema, + createComposedWorldTerminalSignal, + parseComposedWorldTerminalSignal, + publishComposedWorldTerminalSignal, + serializeComposedWorldTerminalSignal, + type ComposedWorldTerminalSignal, +} from "./terminalSignal.js"; diff --git a/src/world-artifact/jungianDialogueExample.test.ts b/src/world-artifact/jungianDialogueExample.test.ts new file mode 100644 index 0000000..eee1018 --- /dev/null +++ b/src/world-artifact/jungianDialogueExample.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const exampleRoot = path.join(packageRoot, "examples", "jungian-dialogue"); +const simfilePath = path.join(exampleRoot, "Simfile"); +const spawnfilePath = path.join(exampleRoot, "org", "Spawnfile"); +const digest = `sha256:${"a".repeat(64)}`; + +test("jungian dialogue prepares two authenticated world members and exact replay truth", async () => { + await ensurePublicPackageBuild(packageRoot); + const binding = await import(`${pathToFileURL( + path.join(exampleRoot, "binding.mjs"), + ).href}?contract=${Date.now()}`) as { + composedProjectBinding: { + prepareComposedProject(input: unknown): Promise; + version: string; + }; + }; + const kernelModule = await import(pathToFileURL( + path.join(exampleRoot, "binding-world.mjs"), + ).href) as { loadKernel(runId: string, seed: string): Promise }; + const runId = "jungian-dialogue-contract"; + const seed = "jungian-dialogue-contract-seed"; + const preparation = await binding.composedProjectBinding.prepareComposedProject({ + base_image_config_digest: digest, + evidence_root: "/var/lib/simfile/evidence", + internal_port: 4070, + organization_container_name: "jungian-dialogue-contract", + platform: { architecture: process.arch === "arm64" ? "arm64" : "amd64", os: "linux" }, + run_id: runId, + secret_root: "/run/spawnfile-secrets", + seed, + simfile_path: simfilePath, + spawnfile_path: spawnfilePath, + }); + + assert.equal(binding.composedProjectBinding.version, "simfile.composed-project-binding.v1"); + assert.equal(preparation.terminal_tick, 12); + assert.deepEqual(preparation.world_members.map((member: any) => member.id), [ + "analyst", "daimon", + ]); + assert.deepEqual(preparation.world_members.map((member: any) => member.principal_id), [ + "agent:analyst", "agent:daimon", + ]); + assert.deepEqual(preparation.credentials.map((credential: any) => credential.env), [ + "SIMFILE_WORLD_TOKEN_ANALYST", "SIMFILE_WORLD_TOKEN_DAIMON", + ]); + assert.equal(preparation.evidence_artifacts.length, 10); + assert.equal(preparation.readiness_expectation.capability_manifest_digests.length, 2); + assert.equal(preparation.readiness_expectation.capabilities.length, 1); + assert.equal(preparation.readiness_expectation.capabilities[0].manifest_digest, + preparation.readiness_expectation.capability_manifest_digests[0]); + assert.ok(preparation.world_members.every( + (member: any) => member.capability_manifest?.holder?.principal === member.principal_id, + )); + + const kernel = await kernelModule.loadKernel(runId, seed); + const replayState = await preparation.replay_adapter.restore(kernel.checkpoint); + const replay = await preparation.replay_adapter.finish(replayState); + assert.equal(replay.terminal_tick, 12); + const terminal = JSON.parse(new TextDecoder().decode(replay.terminal_state)); + assert.equal(terminal.dynamics.next_tick, 12); + assert.equal(terminal.dynamics.provider_state.elapsed_seconds, 12); + const probe = JSON.parse(new TextDecoder().decode(replay.probe)); + assert.deepEqual(probe, { + dialogue_evidence: "spawnfile_moltnet_export", + live_agent_action: "not_evaluated", + passed: true, + run_id: runId, + terminal_tick: 12, + version: "simfile.composed-lifecycle-replay-smoke.v1", + }); + await assert.rejects(Promise.resolve().then(() => preparation.replay_adapter.inject()), + /accepts no recorded actions/u); +}); + +test("jungian screenplay is a bounded five-message mention chain with no model auth", async () => { + const engine = await import(pathToFileURL( + path.join(exampleRoot, "harness", "jungian-engine.mjs"), + ).href) as { + dreamOpeningText(dream: { dread: number; symbols: string[] }): string; + scriptedReply(agent: string, prompt: string): string; + }; + const messages = [engine.dreamOpeningText({ + dread: 0.72, + symbols: ["black door", "tarnished mirror", "lost child"], + })]; + messages.push(engine.scriptedReply("daimon", messages[0]!)); + messages.push(engine.scriptedReply("analyst", messages[1]!)); + messages.push(engine.scriptedReply("daimon", messages[2]!)); + messages.push(engine.scriptedReply("analyst", messages[3]!)); + assert.equal(messages.length, 5); + assert.ok(messages.every((message) => message.length > 40)); + assert.ok(messages.slice(0, 4).every((message) => /@(analyst|daimon)\b/u.test(message))); + assert.doesNotMatch(messages[4]!, /@[\w-]+/u); + assert.match(messages.join("\n"), /black door[\s\S]*tarnished mirror[\s\S]*lost child/u); + + const spawnfiles = await Promise.all([ + spawnfilePath, + path.join(exampleRoot, "org", "agents", "analyst", "Spawnfile"), + path.join(exampleRoot, "org", "agents", "daimon", "Spawnfile"), + ].map((file) => readFile(file, "utf8"))); + assert.match(spawnfiles[0]!, /members:\s*[\s\S]*id: analyst[\s\S]*id: daimon/u); + assert.match(spawnfiles[0]!, /id: consulting-room[\s\S]*members: \[analyst, daimon\]/u); + assert.ok(spawnfiles.slice(1).every((source) => /engine: scripted/u.test(source))); + assert.doesNotMatch(spawnfiles.join("\n"), /(?:api[_-]?key|model:|grok|agy|openai)/iu); +}); + +test("jungian example is package-relative and follows implementation-folder guide links", async () => { + for (const relative of [ + "binding.mjs", "binding-world.mjs", "harness/jungian-engine.mjs", + "world/composer.mjs", "world/evidence.mjs", "world/provider.mjs", "world/surface.mjs", + ]) { + const source = await readFile(path.join(exampleRoot, relative), "utf8"); + assert.doesNotMatch(source, + /(?:\/Users\/[^/]+\/|\/home\/[^/]+\/|[A-Za-z]:\\Users\\[^\\]+\\|\.\.\/spawnfile)/iu, + relative); + } + for (const link of [ + "CLAUDE.md", "harness/CLAUDE.md", "org/CLAUDE.md", + "org/agents/analyst/CLAUDE.md", "org/agents/daimon/CLAUDE.md", "world/CLAUDE.md", + ]) { + assert.equal((await lstat(path.join(exampleRoot, link))).isSymbolicLink(), true, link); + } +}); diff --git a/src/world-artifact/terminalSignal.test.ts b/src/world-artifact/terminalSignal.test.ts new file mode 100644 index 0000000..56e4ae5 --- /dev/null +++ b/src/world-artifact/terminalSignal.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMPOSED_WORLD_TERMINAL_ARTIFACT, + createComposedWorldTerminalSignal, + parseComposedWorldTerminalSignal, + serializeComposedWorldTerminalSignal, +} from "./terminalSignal.js"; + +test("composed world terminal signal is a canonical public authoring contract", () => { + const signal = createComposedWorldTerminalSignal({ + outcome_digest: `sha256:${"a".repeat(64)}`, + reason: "completed", + run_id: "run-one", + terminal_tick: 4, + }); + assert.deepEqual(COMPOSED_WORLD_TERMINAL_ARTIFACT, { + id: "composed_terminal", + max_bytes: 131_072, + path: "/tmp/spawnfile-public/composed-terminal.json", + }); + assert.deepEqual(parseComposedWorldTerminalSignal(signal), signal); + assert.deepEqual( + JSON.parse(new TextDecoder().decode(serializeComposedWorldTerminalSignal(signal))), + signal, + ); + assert.throws(() => parseComposedWorldTerminalSignal({ ...signal, extra: true })); +}); diff --git a/src/world-artifact/terminalSignal.ts b/src/world-artifact/terminalSignal.ts new file mode 100644 index 0000000..17f8479 --- /dev/null +++ b/src/world-artifact/terminalSignal.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { canonicalJson } from "../dynamics/buildIdentity.js"; + +export const COMPOSED_WORLD_TERMINAL_SIGNAL_VERSION = + "simfile.composed-world-terminal-signal.v1" as const; +export const COMPOSED_WORLD_TERMINAL_ARTIFACT = Object.freeze({ + id: "composed_terminal", + max_bytes: 131_072, + path: "/tmp/spawnfile-public/composed-terminal.json", +}); + +export const composedWorldTerminalSignalSchema = z.object({ + outcome_digest: z.string().regex(/^sha256:[a-f0-9]{64}$/u), + reason: z.enum(["completed", "interrupted"]), + run_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u), + terminal_tick: z.number().int().min(1).max(1_000_000_000), + version: z.literal(COMPOSED_WORLD_TERMINAL_SIGNAL_VERSION), +}).strict(); + +export type ComposedWorldTerminalSignal = z.infer< + typeof composedWorldTerminalSignalSchema +>; + +export const parseComposedWorldTerminalSignal = ( + raw: unknown, +): ComposedWorldTerminalSignal => Object.freeze( + composedWorldTerminalSignalSchema.parse(raw), +); + +export const createComposedWorldTerminalSignal = ( + fields: Omit, +): ComposedWorldTerminalSignal => parseComposedWorldTerminalSignal({ + ...fields, + version: COMPOSED_WORLD_TERMINAL_SIGNAL_VERSION, +}); + +export const serializeComposedWorldTerminalSignal = ( + signal: ComposedWorldTerminalSignal, +): Uint8Array => new TextEncoder().encode( + `${canonicalJson(parseComposedWorldTerminalSignal(signal))}\n`, +); + +/** Atomically publishes the one fixed public artifact watched by composition. */ +export const publishComposedWorldTerminalSignal = async ( + signal: ComposedWorldTerminalSignal, +): Promise => { + const directory = path.dirname(COMPOSED_WORLD_TERMINAL_ARTIFACT.path); + const temporary = path.join( + directory, + `.composed-terminal-${process.pid}-${randomUUID()}.tmp`, + ); + await mkdir(directory, { recursive: true }); + try { + await writeFile(temporary, serializeComposedWorldTerminalSignal(signal), { + flag: "wx", + mode: 0o644, + }); + await rename(temporary, COMPOSED_WORLD_TERMINAL_ARTIFACT.path); + } finally { + await unlink(temporary).catch(() => {}); + } +}; diff --git a/tools/AGENTS.md b/tools/AGENTS.md index c823544..6de20b2 100644 --- a/tools/AGENTS.md +++ b/tools/AGENTS.md @@ -7,4 +7,6 @@ operations deterministic and fail-closed. - `refreshVendorStele.mjs` refreshes the integrity-pinned source tarball while preserving Simfile's release-safe exact dependency coordinate and bundle. - `verify-package-closure.mjs` packs and offline-installs Simfile, checks the - bundled Stele closure and runtime imports, and starts the installed CLI. + bundled Stele closure and runtime imports, and starts the installed CLI. Its + contract and isolated-install helpers live in `package-closure-contract.mjs` + and `package-closure-install.mjs`. diff --git a/tools/package-closure-contract.mjs b/tools/package-closure-contract.mjs new file mode 100644 index 0000000..45b5c71 --- /dev/null +++ b/tools/package-closure-contract.mjs @@ -0,0 +1,109 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +const STELE = "@noopolis/stele"; +export const STELE_VERSION = "0.0.2"; + +export const fail = (message) => { throw new Error(message); }; +export const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); + +export const parseSinglePack = (stdout) => { + let parsed; + for (let index = stdout.lastIndexOf("["); index >= 0; + index = stdout.lastIndexOf("[", index - 1)) { + try { + const candidate = JSON.parse(stdout.slice(index)); + if (Array.isArray(candidate)) { parsed = candidate; break; } + } catch { + // Lifecycle scripts may write before npm's final JSON array. + } + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + fail("npm pack must report exactly one tarball"); + } + const [result] = parsed; + if (!result || typeof result.filename !== "string" || !Array.isArray(result.files)) { + fail("npm pack returned an invalid manifest"); + } + return result; +}; + +export const assertRegistrySource = async (packageRoot, manifest, lock) => { + if (manifest.dependencies?.[STELE] !== STELE_VERSION) { + fail(`${STELE} must use the published ${STELE_VERSION} release coordinate`); + } + if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { + fail("published registry dependencies must not be bundled"); + } + for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { + if (typeof coordinate !== "string" || /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`runtime dependency ${name} is not a registry coordinate`); + } + } + for (const [location, entry] of Object.entries(lock.packages ?? {})) { + if (location === "") continue; + if (entry?.link === true + || (typeof entry?.resolved === "string" && /^(?:file|link):/u.test(entry.resolved))) { + fail(`package lock contains a checkout-relative dependency at ${location}`); + } + } + const locked = lock.packages?.[`node_modules/${STELE}`]; + const expected = `https://registry.npmjs.org/@noopolis/stele/-/stele-${STELE_VERSION}.tgz`; + if (!locked || locked.version !== STELE_VERSION || locked.resolved !== expected) { + fail(`${STELE} lock entry must resolve to the exact npm registry tarball`); + } + if (typeof locked.integrity !== "string" || !locked.integrity.startsWith("sha512-")) { + fail(`${STELE} registry lock is missing sha512 integrity`); + } + const installed = path.join(packageRoot, "node_modules", STELE); + if ((await lstat(installed)).isSymbolicLink()) { + fail(`${STELE} must be physically installed; source-checkout links are rejected`); + } + if ((await readJson(path.join(installed, "package.json"))).version !== STELE_VERSION) { + fail(`${STELE} installed version drifted`); + } + return expected; +}; + +export const assertPackedManifest = (manifest) => { + if (manifest.version !== "0.0.3") fail("packed Simfile version drifted"); + if (manifest.dependencies?.[STELE] !== STELE_VERSION) fail(`packed ${STELE} coordinate drifted`); + if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { + fail("packed manifest unexpectedly bundles registry dependencies"); + } + for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { + if (typeof coordinate === "string" && /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`packed dependency ${name} retains a checkout-relative coordinate`); + } + } +}; + +export const assertDevelopmentAssets = (entries) => { + const required = [ + "examples/jungian-dialogue/README.md", + "examples/jungian-dialogue/Simfile", + "examples/jungian-dialogue/binding.mjs", + "examples/jungian-dialogue/harness/jungian-engine.mjs", + "examples/jungian-dialogue/org/Spawnfile", + "examples/jungian-dialogue/org/agents/analyst/Spawnfile", + "examples/jungian-dialogue/org/agents/daimon/Spawnfile", + "scripts/bounded-process.mjs", + "scripts/simfile-local-example.mjs", + "scripts/spawnfile-capability-probe.mjs", + "scripts/spawnfile-composed-smoke.mjs", + "scripts/spawnfile-development-context.mjs", + "scripts/spawnfile-development-setup.mjs", + "scripts/spawnfile-development.mjs", + "scripts/spawnfile-install-integrity.mjs", + "scripts/spawnfile-local-endpoint.mjs", + "scripts/spawnfile-source-stage.mjs", + ]; + for (const entry of required) { + if (!entries.includes(entry)) fail(`packed tarball omitted required development asset ${entry}`); + } + if (entries.some((entry) => entry.includes(".test.") || entry.includes(".test-helper."))) { + fail("packed tarball leaked development test files"); + } +}; + +export const stelePackageName = STELE; diff --git a/tools/package-closure-install.mjs b/tools/package-closure-install.mjs new file mode 100644 index 0000000..d0eccc6 --- /dev/null +++ b/tools/package-closure-install.mjs @@ -0,0 +1,106 @@ +import { lstat, mkdir, realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { runBoundedProcess } from "../scripts/bounded-process.mjs"; +import { + fail, + readJson, + stelePackageName as STELE, + STELE_VERSION, +} from "./package-closure-contract.mjs"; + +export const runPackageClosureProcess = ( + command, args, cwd, env = process.env, +) => runBoundedProcess(command, args, { cwd, env, timeoutMs: 10 * 60 * 1000 }); + +const dependencyRoot = async (installRoot, installedRoot) => { + for (const candidate of [ + path.join(installedRoot, "node_modules", STELE), + path.join(installRoot, "node_modules", STELE), + ]) { + try { await lstat(candidate); return candidate; } + catch (error) { if (error?.code !== "ENOENT") throw error; } + } + fail(`${STELE} was not installed from the packed Simfile dependency graph`); +}; + +const assertPackedExampleBuild = async (packedRoot, scratchRoot) => { + const exampleRoot = path.join(packedRoot, "examples", "jungian-dialogue"); + const binding = await import(pathToFileURL(path.join(exampleRoot, "binding.mjs")).href); + if (typeof binding.composedProjectBinding?.prepareComposedProject !== "function") { + fail("packed composed example binding is unavailable"); + } + const preparation = await binding.composedProjectBinding.prepareComposedProject({ + base_image_config_digest: `sha256:${"a".repeat(64)}`, + evidence_root: path.join(scratchRoot, "example-evidence"), internal_port: 4070, + organization_container_name: "package-closure-example", + platform: { architecture: process.arch === "arm64" ? "arm64" : "amd64", os: "linux" }, + run_id: "package-closure-example", secret_root: path.join(scratchRoot, "example-secrets"), + seed: "package-closure-example-seed", simfile_path: path.join(exampleRoot, "Simfile"), + spawnfile_path: path.join(exampleRoot, "org", "Spawnfile"), + }); + if (!Array.isArray(preparation.bundle?.archive_bytes) + || preparation.bundle.archive_bytes.length < 1) fail("packed composed example bundle archive is empty"); + if (!/^sha256:[a-f0-9]{64}$/u.test(preparation.bundle.manifest?.digest ?? "")) { + fail("packed composed example bundle digest is invalid"); + } + if (preparation.evidence_artifacts?.length !== 10) { + fail("packed composed example evidence mapping is incomplete"); + } + return preparation.bundle.manifest.digest; +}; + +export const buildPackedExample = async (temporaryRoot, tarballPath) => { + const packedRoot = path.join(temporaryRoot, "packed-package"); + const scratchRoot = path.join(temporaryRoot, "packed-example-scratch"); + await Promise.all([mkdir(packedRoot, { recursive: true }), mkdir(scratchRoot, { recursive: true })]); + await runPackageClosureProcess( + "tar", ["-xzf", tarballPath, "--strip-components=1", "-C", packedRoot], temporaryRoot, + ); + await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", + "--omit=dev", "--registry=https://registry.npmjs.org"], packedRoot); + return assertPackedExampleBuild(await realpath(packedRoot), await realpath(scratchRoot)); +}; + +export const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { + await writeFile(path.join(installRoot, "package.json"), `${JSON.stringify({ + name: "simfile-package-closure-consumer", private: true, version: "1.0.0", + }, null, 2)}\n`, "utf8"); + await runPackageClosureProcess("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", + "--registry=https://registry.npmjs.org", tarballPath], installRoot); + const installedRoot = path.join(installRoot, "node_modules", manifest.name); + if ((await lstat(installedRoot)).isSymbolicLink()) fail("Simfile installed as a source link"); + const simfile = await import(pathToFileURL(path.join(installedRoot, "dist/index.js")).href); + if (typeof simfile.parseSimfileSource !== "function") fail("installed Simfile public import is incomplete"); + const steleRoot = await dependencyRoot(installRoot, installedRoot); + if ((await lstat(steleRoot)).isSymbolicLink()) fail(`${STELE} installed as a source link`); + const steleManifest = await readJson(path.join(steleRoot, "package.json")); + if (steleManifest.version !== STELE_VERSION) fail(`${STELE} installed version drifted`); + const steleImport = steleManifest.exports?.["."]?.import; + if (typeof steleImport !== "string" || !steleImport.startsWith("./")) { + fail(`${STELE} does not expose a package-relative ESM entrypoint`); + } + const installRealRoot = await realpath(installRoot); + const steleRealPath = await realpath(path.resolve(steleRoot, steleImport)); + if (!steleRealPath.startsWith(`${installRealRoot}${path.sep}`)) { + fail(`${STELE} resolved outside the isolated install`); + } + 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", "simfile"); + const help = await runPackageClosureProcess(executable, ["--help"], installRoot, { + ...process.env, PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}`, + }); + if (!help.stdout.startsWith("Usage:\n") || !help.stdout.includes("simfile run ")) { + fail("installed Simfile executable did not invoke the CLI entrypoint"); + } + const importProbe = path.join(installRoot, "import-cli.mjs"); + const installedCli = path.join(installedRoot, manifest.bin.simfile); + await writeFile(importProbe, `await import(${JSON.stringify(pathToFileURL(installedCli).href)});\nprocess.stdout.write("import-only-ok\\n");\n`, "utf8"); + const imported = await runPackageClosureProcess(process.execPath, [importProbe], installRoot); + if (imported.stdout !== "import-only-ok\n" || imported.stderr !== "") { + fail("importing the installed CLI produced entrypoint side effects"); + } + return { steleResolved: path.relative(installRealRoot, steleRealPath) }; +}; diff --git a/tools/verify-package-closure.mjs b/tools/verify-package-closure.mjs index 4c6f6a6..c8a713a 100644 --- a/tools/verify-package-closure.mjs +++ b/tools/verify-package-closure.mjs @@ -1,192 +1,40 @@ #!/usr/bin/env node - -import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const STELE = "@noopolis/stele"; -const STELE_VERSION = "0.0.2"; - -const fail = (message) => { - throw new Error(message); -}; - -const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); +import { fileURLToPath } from "node:url"; -const run = (command, args, cwd, env = process.env) => new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { stdout += chunk; }); - child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(`${command} ${args.join(" ")} failed (${code})\n${stderr}`)); - return; - } - resolve({ stderr, stdout }); - }); -}); - -const parseSinglePack = (stdout) => { - let parsed; - for (let index = stdout.lastIndexOf("["); index >= 0; index = stdout.lastIndexOf("[", index - 1)) { - try { - const candidate = JSON.parse(stdout.slice(index)); - if (Array.isArray(candidate)) { - parsed = candidate; - break; - } - } catch { - // Lifecycle scripts may write before npm's final JSON array. - } - } - if (!Array.isArray(parsed) || parsed.length !== 1) { - fail("npm pack must report exactly one tarball"); - } - const [result] = parsed; - if (!result || typeof result.filename !== "string" || !Array.isArray(result.files)) { - fail("npm pack returned an invalid manifest"); - } - return result; -}; +import { + assertDevelopmentAssets, + assertPackedManifest, + assertRegistrySource, + fail, + parseSinglePack, + readJson, +} from "./package-closure-contract.mjs"; +import { + assertInstalledClosure, + buildPackedExample, + runPackageClosureProcess, +} from "./package-closure-install.mjs"; -const assertRegistrySource = async (manifest, lock) => { - if (manifest.dependencies?.[STELE] !== STELE_VERSION) { - fail(`${STELE} must use the published ${STELE_VERSION} release coordinate`); - } - if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { - fail("published registry dependencies must not be bundled"); - } - for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { - if (typeof coordinate !== "string" || /^(?:file|link|workspace):/u.test(coordinate)) { - fail(`runtime dependency ${name} is not a registry coordinate`); - } - } - for (const [location, entry] of Object.entries(lock.packages ?? {})) { - if (location === "") continue; - if (entry?.link === true || (typeof entry?.resolved === "string" && /^(?:file|link):/u.test(entry.resolved))) { - fail(`package lock contains a checkout-relative dependency at ${location}`); - } - } - const locked = lock.packages?.[`node_modules/${STELE}`]; - const expected = `https://registry.npmjs.org/@noopolis/stele/-/stele-${STELE_VERSION}.tgz`; - if (!locked || locked.version !== STELE_VERSION || locked.resolved !== expected) { - fail(`${STELE} lock entry must resolve to the exact npm registry tarball`); - } - if (typeof locked.integrity !== "string" || !locked.integrity.startsWith("sha512-")) { - fail(`${STELE} registry lock is missing sha512 integrity`); - } - const installed = path.join(packageRoot, "node_modules", STELE); - if ((await lstat(installed)).isSymbolicLink()) { - fail(`${STELE} must be physically installed; source-checkout links are rejected`); - } - if ((await readJson(path.join(installed, "package.json"))).version !== STELE_VERSION) { - fail(`${STELE} installed version drifted`); - } - return expected; -}; - -const assertPackedManifest = (manifest) => { - if (manifest.version !== "0.0.2") fail("packed Simfile version drifted"); - if (manifest.dependencies?.[STELE] !== STELE_VERSION) { - fail(`packed ${STELE} coordinate drifted`); - } - if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { - fail("packed manifest unexpectedly bundles registry dependencies"); - } - for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { - if (typeof coordinate === "string" && /^(?:file|link|workspace):/u.test(coordinate)) { - fail(`packed dependency ${name} retains a checkout-relative coordinate`); - } - } -}; - -const dependencyRoot = async (installRoot, installedRoot) => { - for (const candidate of [ - path.join(installedRoot, "node_modules", STELE), - path.join(installRoot, "node_modules", STELE) - ]) { - try { - await lstat(candidate); - return candidate; - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - fail(`${STELE} was not installed from the packed Simfile dependency graph`); -}; - -const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { - await writeFile(path.join(installRoot, "package.json"), "{\"private\":true}\n", "utf8"); - await run("npm", [ - "install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", - "--registry=https://registry.npmjs.org", tarballPath - ], installRoot); - const installedRoot = path.join(installRoot, "node_modules", manifest.name); - if ((await lstat(installedRoot)).isSymbolicLink()) fail("Simfile installed as a source link"); - const simfile = await import(pathToFileURL(path.join(installedRoot, "dist/index.js")).href); - if (typeof simfile.parseSimfileSource !== "function") fail("installed Simfile public import is incomplete"); - - const steleRoot = await dependencyRoot(installRoot, installedRoot); - if ((await lstat(steleRoot)).isSymbolicLink()) fail(`${STELE} installed as a source link`); - const steleManifest = await readJson(path.join(steleRoot, "package.json")); - if (steleManifest.version !== STELE_VERSION) fail(`${STELE} installed version drifted`); - const steleImport = steleManifest.exports?.["."]?.import; - if (typeof steleImport !== "string" || !steleImport.startsWith("./")) { - fail(`${STELE} does not expose a package-relative ESM entrypoint`); - } - const installRealRoot = await realpath(installRoot); - const steleRealPath = await realpath(path.resolve(steleRoot, steleImport)); - if (!steleRealPath.startsWith(`${installRealRoot}${path.sep}`)) { - fail(`${STELE} resolved outside the isolated install`); - } - 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", "simfile"); - const help = await run(executable, ["--help"], installRoot, { - ...process.env, - PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}` - }); - if (!help.stdout.startsWith("Usage:\n") || !help.stdout.includes("simfile run ")) { - fail("installed Simfile executable did not invoke the CLI entrypoint"); - } - const importProbe = path.join(installRoot, "import-cli.mjs"); - const installedCli = path.join(installedRoot, manifest.bin.simfile); - await writeFile(importProbe, [ - `await import(${JSON.stringify(pathToFileURL(installedCli).href)});`, - 'process.stdout.write("import-only-ok\\n");', - "" - ].join("\n"), "utf8"); - const imported = await run(process.execPath, [importProbe], installRoot); - if (imported.stdout !== "import-only-ok\n" || imported.stderr !== "") { - fail("importing the installed CLI produced entrypoint side effects"); - } - return path.relative(installRealRoot, steleRealPath); -}; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const main = async () => { const manifest = await readJson(path.join(packageRoot, "package.json")); const lock = await readJson(path.join(packageRoot, "package-lock.json")); - const steleRegistryTarball = await assertRegistrySource(manifest, lock); + const steleRegistryTarball = await assertRegistrySource(packageRoot, manifest, lock); const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-closure-")); try { const packDirectory = path.join(temporaryRoot, "pack"); const installRoot = path.join(temporaryRoot, "install"); await Promise.all([ mkdir(packDirectory, { recursive: true }), - mkdir(installRoot, { recursive: true }) + mkdir(installRoot, { recursive: true }), ]); - const packed = parseSinglePack((await run( - "npm", ["pack", "--json", "--pack-destination", packDirectory], packageRoot + const packed = parseSinglePack((await runPackageClosureProcess( + "npm", ["pack", "--json", "--pack-destination", packDirectory], packageRoot, )).stdout); const tarballPath = path.join(packDirectory, packed.filename); const packedBytes = await readFile(tarballPath); @@ -202,20 +50,20 @@ const main = async () => { || entry.includes("ecosystem/") || entry.startsWith("vendor/"))) { fail("packed tarball leaked a fixture, dependency, source checkout, or vendor archive"); } - const packedManifest = JSON.parse((await run( - "tar", ["-xOf", tarballPath, "package/package.json"], packageRoot + assertDevelopmentAssets(entries); + const packedManifest = JSON.parse((await runPackageClosureProcess( + "tar", ["-xOf", tarballPath, "package/package.json"], packageRoot, )).stdout); assertPackedManifest(packedManifest); - const steleResolved = await assertInstalledClosure(installRoot, manifest, tarballPath); + const installed = await assertInstalledClosure(installRoot, manifest, tarballPath); + const exampleBundleDigest = await buildPackedExample(temporaryRoot, tarballPath); process.stdout.write(`${JSON.stringify({ - bundled: packed.bundled ?? [], - entries: packed.entryCount, - integrity: packed.integrity, - package: packed.id, - packed_file: packed.filename, + bundled: packed.bundled ?? [], entries: packed.entryCount, + integrity: packed.integrity, package: packed.id, packed_file: packed.filename, + packed_example_bundle_digest: exampleBundleDigest, runtime_dependencies: packedManifest.dependencies, stele_registry_tarball: steleRegistryTarball, - stele_resolved_inside_install: steleResolved + stele_resolved_inside_install: installed.steleResolved, }, null, 2)}\n`); } finally { await rm(temporaryRoot, { force: true, recursive: true }); diff --git a/web/src/store/AGENTS.md b/web/src/store/AGENTS.md index 8dd62be..1705351 100644 --- a/web/src/store/AGENTS.md +++ b/web/src/store/AGENTS.md @@ -10,7 +10,8 @@ clocks). - `timeline.ts` — `timelineStore` (a plain pub/sub external store, not React Context) holding the loaded `RunTimeline`, the integer scrub cursor, playback state (`playing`/`speed`), the current `selection` (an - `ElementRef` — the map/chat focus), the `openPortals` stack (every + `ElementRef` — the map/chat focus), `activePanel` (the URL/user/default + choice between Conversation and Map), the `openPortals` stack (every currently open storyline portal, any element kind), and `highlightedEventIds` (the recall -> turn cross-portal linked-selection set). Exposes action functions (`loadTimeline`, `setCursor`, `stepBy`, @@ -34,11 +35,13 @@ clocks). `/api/run-meta`'s `variableSamples`, joined by `../viewer/variableModel.ts`, never duplicated into this store). - `deepLink.ts` — pure `parseDeepLink`/`serializeDeepLink`/`currentDeepLink`/ - `applyDeepLink` over the `?at=&sel=&portals=` + `applyDeepLink` over the + `?at=&sel=&portals=&panel=` URL shape (anchored on `event_id`, never the dense index `t`, so a link survives timeline re-derivation), plus `startDeepLinkSync` — the only function here that touches `window.history`/`window.location`, throttled - so scrubbing doesn't spam `replaceState`. + so scrubbing doesn't spam `replaceState`. Invalid explicit panel values + fail closed to Map. ## Rules diff --git a/web/src/store/deepLink.test.ts b/web/src/store/deepLink.test.ts index 8d1e9cc..7d798be 100644 --- a/web/src/store/deepLink.test.ts +++ b/web/src/store/deepLink.test.ts @@ -7,6 +7,7 @@ import { resetTimelineStoreForTests, setCursor, setOpenPortals, + setActivePanel, setSelection, timelineStore, type RunTimeline, @@ -37,31 +38,32 @@ describe("deepLink", () => { resetTimelineStoreForTests(); }); - it("parses at/sel/portals from a query string, with or without the leading '?'", () => { - const parsed = parseDeepLink("?at=event-2&sel=agent:eleanor&portals=room:net:room,bank:office-recall"); - assert.deepEqual(parsed, { at: "event-2", sel: "agent:eleanor", portals: ["room:net:room", "bank:office-recall"] }); + it("parses at/sel/portals/panel from a query string, with or without the leading '?'", () => { + const parsed = parseDeepLink("?at=event-2&sel=agent:eleanor&portals=room:net:room,bank:office-recall&panel=conversation"); + assert.deepEqual(parsed, { at: "event-2", sel: "agent:eleanor", portals: ["room:net:room", "bank:office-recall"], panel: "conversation" }); const withoutLeadingMark = parseDeepLink("at=event-2&sel=agent:eleanor"); assert.equal(withoutLeadingMark.at, "event-2"); assert.equal(withoutLeadingMark.sel, "agent:eleanor"); assert.deepEqual(withoutLeadingMark.portals, []); + assert.equal(withoutLeadingMark.panel, undefined); }); it("parses an empty search string to all-empty params", () => { const parsed = parseDeepLink(""); - assert.deepEqual(parsed, { at: undefined, sel: undefined, portals: [] }); + assert.deepEqual(parsed, { at: undefined, sel: undefined, portals: [], panel: undefined }); }); it("serializeDeepLink omits absent fields and joins portals with commas", () => { - assert.equal(serializeDeepLink({ at: undefined, sel: undefined, portals: [] }), ""); + assert.equal(serializeDeepLink({ at: undefined, sel: undefined, portals: [], panel: undefined }), ""); assert.equal( - serializeDeepLink({ at: "event-2", sel: "agent:eleanor", portals: ["room:net:room", "bank:x"] }), - "?at=event-2&sel=agent%3Aeleanor&portals=room%3Anet%3Aroom%2Cbank%3Ax", + serializeDeepLink({ at: "event-2", sel: "agent:eleanor", portals: ["room:net:room", "bank:x"], panel: "map" }), + "?at=event-2&sel=agent%3Aeleanor&portals=room%3Anet%3Aroom%2Cbank%3Ax&panel=map", ); }); it("round-trips serialize -> parse to the same params", () => { - const original = { at: "event-3", sel: "bank:office-recall", portals: ["agent:eleanor", "room:net:room"] }; + const original = { at: "event-3", sel: "bank:office-recall", portals: ["agent:eleanor", "room:net:room"], panel: "conversation" as const }; const roundTripped = parseDeepLink(serializeDeepLink(original)); assert.deepEqual(roundTripped, original); }); @@ -71,21 +73,28 @@ describe("deepLink", () => { setCursor(2); setSelection("room:net:room"); setOpenPortals(["room:net:room"]); + setActivePanel("conversation"); const current = currentDeepLink(timelineStore.getSnapshot()); assert.equal(current.at, "event-2"); assert.equal(current.sel, "room:net:room"); assert.deepEqual(current.portals, ["room:net:room"]); + assert.equal(current.panel, "conversation"); }); it("applyDeepLink resolves 'at' to the resolved event's current t, and restores selection/portals", () => { loadTimeline(fixtureTimeline()); - applyDeepLink(fixtureTimeline(), { at: "event-3", sel: "room:net:room", portals: ["room:net:room"] }); + applyDeepLink(fixtureTimeline(), { at: "event-3", sel: "room:net:room", portals: ["room:net:room"], panel: "conversation" }); const state = timelineStore.getSnapshot(); assert.equal(state.cursor, 3); assert.equal(state.selection, "room:net:room"); assert.deepEqual(state.openPortals, ["room:net:room"]); + assert.equal(state.activePanel, "conversation"); + }); + + it("fails an invalid explicit panel closed to map", () => { + assert.equal(parseDeepLink("?panel=obsolete").panel, "map"); }); it("applyDeepLink ignores an 'at' event id that isn't in the timeline rather than throwing", () => { @@ -100,6 +109,7 @@ describe("deepLink", () => { setCursor(1); setSelection("room:net:room"); setOpenPortals(["room:net:room"]); + setActivePanel("conversation"); const serialized = serializeDeepLink(currentDeepLink(timelineStore.getSnapshot())); @@ -111,5 +121,6 @@ describe("deepLink", () => { assert.equal(restored.cursor, 1); assert.equal(restored.selection, "room:net:room"); assert.deepEqual(restored.openPortals, ["room:net:room"]); + assert.equal(restored.activePanel, "conversation"); }); }); diff --git a/web/src/store/deepLink.ts b/web/src/store/deepLink.ts index 7be813e..2fcd6f4 100644 --- a/web/src/store/deepLink.ts +++ b/web/src/store/deepLink.ts @@ -1,17 +1,19 @@ import { + setActivePanel, setCursor, setOpenPortals, setSelection, timelineStore, type ElementRef, + type ReplayPanel, type RunTimeline, type TimelineStoreState, } from "./timeline.js"; /** * Deep links (`VIEW_DESIGN.md`: "a view is a value" + increment 2 rule 4): - * `?at=&sel=&portals=` serializes cursor, - * selection, and the open-portal stack into the URL, and restores them on + * `?at=&sel=&portals=&panel=` + * serializes cursor, selection, portals, and the primary replay panel, and restores them on * load. Anchored on `event_id` rather than the dense index `t`, because `t` * is only stable within one `buildRunTimeline` run — a fixture's causal * order can shift between re-derivations while the events themselves (and @@ -26,11 +28,17 @@ export interface DeepLinkParams { at?: string; sel?: string; portals: ElementRef[]; + panel?: ReplayPanel; } const parsePortals = (raw: string | null): ElementRef[] => raw ? raw.split(",").map((ref) => ref.trim()).filter((ref) => ref.length > 0) : []; +const parsePanel = (raw: string | null): ReplayPanel | undefined => { + if (raw === null) return undefined; + return raw === "conversation" ? "conversation" : "map"; +}; + /** Parses a `location.search`-shaped string (with or without the leading `?`) into deep-link params. */ export const parseDeepLink = (search: string): DeepLinkParams => { const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`); @@ -40,6 +48,7 @@ export const parseDeepLink = (search: string): DeepLinkParams => { at: at ?? undefined, sel: sel ?? undefined, portals: parsePortals(params.get("portals")), + panel: parsePanel(params.get("panel")), }; }; @@ -49,15 +58,17 @@ export const serializeDeepLink = (params: DeepLinkParams): string => { if (params.at) usp.set("at", params.at); if (params.sel) usp.set("sel", params.sel); if (params.portals.length > 0) usp.set("portals", params.portals.join(",")); + if (params.panel) usp.set("panel", params.panel); const query = usp.toString(); return query ? `?${query}` : ""; }; /** Reads the deep-link value of the store's current cursor/selection/open-portal state. */ -export const currentDeepLink = (state: Pick): DeepLinkParams => ({ +export const currentDeepLink = (state: Pick): DeepLinkParams => ({ at: state.timeline?.events[state.cursor]?.eventId, sel: state.selection ?? undefined, portals: state.openPortals, + panel: state.activePanel ?? undefined, }); /** @@ -74,6 +85,7 @@ export const applyDeepLink = (timeline: RunTimeline, params: DeepLinkParams): vo } if (params.sel) setSelection(params.sel); if (params.portals.length > 0) setOpenPortals(params.portals); + if (params.panel) setActivePanel(params.panel); }; const THROTTLE_MS = 250; diff --git a/web/src/store/timeline.test.ts b/web/src/store/timeline.test.ts index 8a6974f..d178fd3 100644 --- a/web/src/store/timeline.test.ts +++ b/web/src/store/timeline.test.ts @@ -25,6 +25,7 @@ import { resetTimelineStoreForTests, setCursor, setHighlightedEventIds, + setActivePanel, setOpenPortals, setSelection, setSpeed, @@ -280,6 +281,13 @@ describe("timelineStore", () => { assert.equal(timelineStore.getSnapshot().selection, null); }); + it("tracks the selected replay panel independently of timeline loads", () => { + assert.equal(timelineStore.getSnapshot().activePanel, null); + setActivePanel("conversation"); + loadTimeline(fixtureTimeline()); + assert.equal(timelineStore.getSnapshot().activePanel, "conversation"); + }); + it("maxCursor is 0 for a null timeline and events.length-1 otherwise", () => { assert.equal(maxCursor(null), 0); assert.equal(maxCursor(fixtureTimeline()), 4); diff --git a/web/src/store/timeline.ts b/web/src/store/timeline.ts index 8e91086..5e78e04 100644 --- a/web/src/store/timeline.ts +++ b/web/src/store/timeline.ts @@ -17,6 +17,7 @@ import type { ViewerContractTrace } from "../viewer/types.js"; */ export type ElementRef = string; +export type ReplayPanel = "map" | "conversation"; /** `clock`/`marker` (world's `clock.sync`/`marker.seen`) and `wake` shared with `control.wake.accepted` are increment 3's world-stream additions — see `src/view/runTimelineTypes.ts`'s equivalent doc comment. */ export type TimelineViewClass = @@ -94,6 +95,8 @@ export interface TimelineStoreState { speed: number; /** The current map/chat focus — independent of which portals are open. */ selection: ElementRef | null; + /** The user/deep-link/default choice for the replay's primary surface. */ + activePanel: ReplayPanel | null; /** * Every currently open storyline portal, in open order. Portals stack * (`VIEW_DESIGN.md`: "Portals stack with breadcrumbs") — opening a bank @@ -121,6 +124,7 @@ const initialState = (): TimelineStoreState => ({ playing: false, speed: 1, selection: null, + activePanel: null, openPortals: [], highlightedEventIds: [], loadError: null, @@ -177,6 +181,7 @@ export const togglePlay = (): void => setState({ playing: !state.playing }); export const setSpeed = (speed: number): void => setState({ speed: Math.max(0.25, speed) }); export const setSelection = (ref: ElementRef | null): void => setState({ selection: ref }); +export const setActivePanel = (panel: ReplayPanel): void => setState({ activePanel: panel }); /** Opens `ref`'s storyline portal if it is not already open. Idempotent. */ export const openPortal = (ref: ElementRef): void => { diff --git a/web/src/styles-replay.css b/web/src/styles-replay.css index c9491f0..644a901 100644 --- a/web/src/styles-replay.css +++ b/web/src/styles-replay.css @@ -12,8 +12,8 @@ .replay-grid { min-height: 0; display: grid; - grid-template-areas: "support map conversation"; - grid-template-columns: minmax(220px, 0.75fr) minmax(520px, 2.2fr) minmax(280px, 1fr); + grid-template-areas: "support primary"; + grid-template-columns: minmax(220px, 0.75fr) minmax(520px, 3.2fr); gap: 14px; padding: 14px 16px; overflow: hidden; @@ -21,8 +21,12 @@ .replay-secondary-stack { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; overflow: auto; } .replay-left-stack { grid-area: support; } -.replay-map { grid-area: map; } -.replay-right-stack { grid-area: conversation; } +.replay-primary { grid-area: primary; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); } +.replay-primary-tabs { display: flex; gap: 4px; padding: 0 0 6px; } +.replay-primary-tabs button { border: 1px solid var(--line); border-radius: 3px; background: var(--bg); color: var(--sub); cursor: pointer; font: inherit; font-size: 11px; letter-spacing: .08em; padding: 5px 9px; text-transform: uppercase; } +.replay-primary-tabs button.is-active { border-color: var(--accent); color: var(--accent); } +.replay-primary-content { min-width: 0; min-height: 0; } +.replay-primary-content > .replay-pane { height: 100%; } .action-feed-drawer { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); } .action-feed-drawer > summary { padding: 8px 10px; color: var(--accent); cursor: pointer; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } .action-feed-drawer[open] { min-height: 220px; } @@ -1164,8 +1168,7 @@ details.feed-what > .feed-headline::marker { @media (max-width: 1100px) { .replay-grid { grid-template-areas: - "map" - "conversation" + "primary" "support"; grid-template-columns: minmax(0, 1fr); grid-auto-rows: minmax(220px, auto); diff --git a/web/src/viewer/AGENTS.md b/web/src/viewer/AGENTS.md index db11f03..87750b0 100644 --- a/web/src/viewer/AGENTS.md +++ b/web/src/viewer/AGENTS.md @@ -7,7 +7,7 @@ This folder contains the browser UI for replaying Simfile run records. - `App.tsx` owns data loading, stream subscription, selection state, and the console shell for world/live replay. `../main.tsx` selects it by `/api/state.mode`; do not add run-replay branching inside this file. - `AppRows.tsx` owns the small reusable panel, node-row, and event-row renderers used by the world/live shell. -- `RunReplayShell.tsx` is the sibling shell for run-replay mode (a sealed compose-and-observe run directory): the same `AsciiMap`/`worldModel`/`renderSettings` map, `ReplayPanes.tsx`'s chat/minds panes, a stack of `../portals/StorylinePortal.tsx` (one per entry in `../store/timeline.ts`'s `openPortals`), `RunMetaPanels.tsx`'s engine-provenance badge/verdict strip/provenance drawer/spread readout/variable gauge, and `../chrome/ScrubBar.tsx` as global chrome — all reading the one `timelineStore` cursor. It loads `/api/timeline`, `/api/world`, and `/api/run-meta` itself; it does not touch `/api/events` (run-replay has no live SSE tick). It also owns deep-link wiring: `../store/deepLink.ts`'s `applyDeepLink` restores `?at=&sel=&portals=` once the timeline loads, and `startDeepLinkSync` mirrors store changes back into the URL. Increment 3: derives `utteredEventIds`/`seedSpreadEventIds`/`glowScopes` from `runMeta` + the timeline (via `spreadModel.ts`) and passes them into `ChatPane`, `ScrubBar`, and `AsciiMap` respectively — all empty/undefined when the run has no seed declaration. Increment 4: derives `variableTick` once through `variableModel.ts`'s delegating `tickAtCursor` and passes that same record-owned whole world tick to `AsciiMap`, `ActionFeedPane`, `VariableGaugeRail`, and every open `StorylinePortal` (as the unchanged `variableTick` prop), so no consumer re-derives the cursor join; it remains `undefined` for a record that states no time. Honesty-gap fix: renders `EngineProvenanceBadge` in the topbar right after the run name and before the verdict strip — placement, not just styling, is what makes it unmissable — from `runMeta.engineProvenance`, which is never undefined (see `RunMetaPanels.tsx`/`src/view/engineProvenance.ts`). +- `RunReplayShell.tsx` is the sibling shell for run-replay mode (a sealed compose-and-observe run directory): the same `AsciiMap`/`worldModel`/`renderSettings` map, `ReplayPanes.tsx`'s chat/minds panes, a stack of `../portals/StorylinePortal.tsx` (one per entry in `../store/timeline.ts`'s `openPortals`), `RunMetaPanels.tsx`'s engine-provenance badge/verdict strip/provenance drawer/spread readout/variable gauge, and `../chrome/ScrubBar.tsx` as global chrome — all reading the one `timelineStore` cursor. It loads `/api/timeline`, `/api/world`, and `/api/run-meta` itself; it does not touch `/api/events` (run-replay has no live SSE tick). It also owns deep-link wiring: `../store/deepLink.ts`'s `applyDeepLink` restores `?at=&sel=&portals=&panel=` once the timeline loads, and `startDeepLinkSync` mirrors store changes back into the URL. `ReplayPrimaryPane.tsx` switches the primary surface between Conversation and Map; `replayPanel.ts` selects Conversation only when the complete sealed timeline contains a nonblank message admitted by the exact participant chat projection. An explicit URL panel or existing user choice wins, and default selection runs once so a cursor move or live seal cannot clobber it. Increment 3: derives `utteredEventIds`/`seedSpreadEventIds`/`glowScopes` from `runMeta` + the timeline (via `spreadModel.ts`) and passes them into `ChatPane`, `ScrubBar`, and `AsciiMap` respectively — all empty/undefined when the run has no seed declaration. Increment 4: derives `variableTick` once through `variableModel.ts`'s delegating `tickAtCursor` and passes that same record-owned whole world tick to `AsciiMap`, `ActionFeedPane`, `VariableGaugeRail`, and every open `StorylinePortal` (as the unchanged `variableTick` prop), so no consumer re-derives the cursor join; it remains `undefined` for a record that states no time. Honesty-gap fix: renders `EngineProvenanceBadge` in the topbar right after the run name and before the verdict strip — placement, not just styling, is what makes it unmissable — from `runMeta.engineProvenance`, which is never undefined (see `RunMetaPanels.tsx`/`src/view/engineProvenance.ts`). - `runLifecycle.ts` loads the atomic post-seal timeline/world/meta response used to replace every live-pending fact in one client update. - `runLiveClient.ts` owns the generic temporary timeline and SSE parser used @@ -17,7 +17,7 @@ This folder contains the browser UI for replaying Simfile run records. `spatial.sample` rows carry recorded simulated ticks from the viewer trace and never replace causal events. - `ReplayPanes.tsx` — the minds rail and the compatibility re-export for `ChatPane`. The rail groups memory events by bank then by agent; recorded action principals render as compact one-line rows whose details open in a modal, keeping the map-adjacent rail stable. Every bank/agent header opens its storyline portal through `focusAndOpenPortal` — the same mechanism the map and chat use, never a bespoke open path. -- `ChatPane.tsx` — participant-only room chat. It admits messages only when their actor is an agent declared by the timeline, so world/control-plane records never appear as chat; it keeps recall chips, membrane treatment, world-echo badges on participant speech, and seed badges. +- `ChatPane.tsx` — participant-only room chat. It admits nonblank messages only when their actor is an agent declared by the timeline, so world/control-plane records never appear as chat; it keeps recall chips, membrane treatment, world-echo badges on participant speech, and seed badges. - `actionFeed.ts` — the pure, genre-neutral join primitives over the action attempt/result events already present in `RunTimeline`: the `ActionFeedRow` shape, `joinRecordedActions` (attempt joined to its own result by the run's `act_id`), `summarizeActions`, and `participantRef`, which resolves a recorded participant only to an element ref the timeline enumerates. The module never reads or serves a second action stream. - `actionLog.ts` — the action feed as an APPEND LOG, the prefix convention `ReplayPanes.tsx`'s `ChatPane`/`MindsRail` and `../portals/StorylineRows.tsx` already read. `buildActionLog` orders every recorded action and commitment resolution ONCE per timeline (by tick, then the run's own ordering key, then source position); `actionLogUpToTick` answers a cursor with a binary search and a slice — the log does not move with the cursor, so a cursor change never rescans the event stream (B206). A resolution entry carries an `ActionLogDeclaration`: the declarer, the body declared through, the verb, the target and the tick it was declared at, so an outcome is readable on its own however far below its declaration it lands. `actionsAtTick` remains as the single-query exact-tick slice; a cursor-moving caller must build the log once instead. - `actionNarration.ts` — the generic action presentation vocabulary: structural display names and declaration facts, plus the record-decider axis shared by tests, category derivation, and rendering. `actionDecider` derives declared/derived/refused from the record's stated `provenance`, rejection outcome, and compatibility phase fallback; it never switches on an action name. diff --git a/web/src/viewer/ChatPane.test.ts b/web/src/viewer/ChatPane.test.ts index 9ac16dd..aee5329 100644 --- a/web/src/viewer/ChatPane.test.ts +++ b/web/src/viewer/ChatPane.test.ts @@ -4,7 +4,7 @@ import { describe, it } from "node:test"; import type { RunTimeline, TimelineEvent } from "../store/timeline.js"; import { participantChatMessages } from "./ChatPane.js"; -const message = (actor: string, eventId: string, t: number): TimelineEvent => ({ +const message = (actor: string, eventId: string, t: number, text = eventId): TimelineEvent => ({ actor, authority: "moltnet", causes: [], @@ -15,7 +15,7 @@ const message = (actor: string, eventId: string, t: number): TimelineEvent => ({ streamId: "room", subjects: ["room:shared"], t, - text: eventId, + text, type: "message.accepted", viewClass: "message", }); @@ -34,4 +34,15 @@ describe("participantChatMessages", () => { ["visible-reply"], ); }); + + it("excludes undeclared and blank participant-like messages", () => { + const timeline: RunTimeline = { + elements: [{ kind: "agent", label: "Alpha", ref: "agent:alpha" }], + events: [message("unknown", "undeclared", 0), message("alpha", "blank", 1, " ")], + runId: "run", + version: "simfile.run-timeline.v1", + }; + + assert.deepEqual(participantChatMessages(timeline, 1), []); + }); }); diff --git a/web/src/viewer/ChatPane.tsx b/web/src/viewer/ChatPane.tsx index 35c8bb4..d3c546d 100644 --- a/web/src/viewer/ChatPane.tsx +++ b/web/src/viewer/ChatPane.tsx @@ -40,6 +40,8 @@ export const participantChatMessages = ( return eventsUpTo(timeline, cursor).filter((event) => event.viewClass === "message" && event.actor !== undefined + && typeof event.text === "string" + && event.text.trim().length > 0 && (participantRefs.has(event.actor) || participantRefs.has(`agent:${event.actor}`)) && (!roomFilter || event.subjects.some((subject) => roomFilter.has(subject)))); }; diff --git a/web/src/viewer/ReplayPrimaryPane.tsx b/web/src/viewer/ReplayPrimaryPane.tsx new file mode 100644 index 0000000..0ff05be --- /dev/null +++ b/web/src/viewer/ReplayPrimaryPane.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +import type { ReplayPanel } from "../store/timeline.js"; + +export function ReplayPrimaryPane({ + activePanel, + conversation, + map, + onSelect, +}: { + activePanel: ReplayPanel; + conversation: ReactNode; + map: ReactNode; + onSelect: (panel: ReplayPanel) => void; +}) { + return ( +
+ +
+ {activePanel === "conversation" ? conversation : map} +
+
+ ); +} diff --git a/web/src/viewer/RunReplayShell.tsx b/web/src/viewer/RunReplayShell.tsx index 10f0ca0..ebbd04c 100644 --- a/web/src/viewer/RunReplayShell.tsx +++ b/web/src/viewer/RunReplayShell.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { focusAndOpenPortal, loadTimeline, + setActivePanel, setCursor, setLoadError, useTimelineStore, @@ -16,6 +17,7 @@ import { ActionFeedPane } from "./ActionFeedPane.js"; import { AsciiMap } from "./AsciiMap.js"; import { actionLogUpToTick, buildActionLog, type ActionLog } from "./actionLog.js"; import { ChatPane, MindsRail } from "./ReplayPanes.js"; +import { ReplayPrimaryPane } from "./ReplayPrimaryPane.js"; import { EngineProvenanceBadge, ProvenancePanel, @@ -32,6 +34,7 @@ import { buildViewerWorld, viewerSkins } from "./worldModel.js"; import { buildWorldMapRendererFrame } from "./worldMapRendererFrame.js"; import { worldMapPresentationTick } from "./worldMapRendererCatalog.js"; import { fetchSealedRunLifecycle } from "./runLifecycle.js"; +import { initialReplayPanel } from "./replayPanel.js"; import { livePendingProvenance, liveTimeline, @@ -62,7 +65,7 @@ const fetchJson = async (url: string): Promise => { const ignoreSelection = (): void => {}; export function RunReplayShell() { - const { timeline, cursor, selection, openPortals, loadError } = useTimelineStore(); + const { timeline, cursor, selection, activePanel, openPortals, loadError } = useTimelineStore(); const [worldTrace, setWorldTrace] = useState(null); const [worldError, setWorldError] = useState(null); const [runMeta, setRunMeta] = useState(null); @@ -174,8 +177,12 @@ export function RunReplayShell() { // event id to its *current* `t`. if (!timeline || deepLinkApplied.current) return; deepLinkApplied.current = true; - applyDeepLink(timeline, parseDeepLink(window.location.search)); - }, [timeline]); + const params = parseDeepLink(window.location.search); + applyDeepLink(timeline, { + ...params, + panel: initialReplayPanel(timeline, params.panel, activePanel), + }); + }, [timeline, activePanel]); const world = useMemo(() => (worldTrace ? buildViewerWorld(worldTrace) : null), [worldTrace]); const spatialTickSpan = useMemo<{ firstTick?: number; lastTick?: number }>(() => { @@ -323,43 +330,46 @@ export function RunReplayShell() { -
-
world map
- {caption ?

{caption}

: null} - {world && selectedNode ? ( - { - const node = world.nodes.find((candidate) => candidate.id === id); - if (node) focusAndOpenPortal(node.scope); - }} - renderSettings={defaultRenderSettings} - roomPaths={world.roomPaths} - rooms={world.roomGeometries} - selectedNode={selectedNode} - selectedSkin={skin} - presenceByAgent={world.presenceByAgent} - spatialSamples={world.spatialSamples} - tick={presentationTick ?? variableTick} - tickDurationMs={world.tickDurationMs} - extensionData={world.viewerExtensionData} - extensionIdentities={world.viewerExtensionIdentities} - cursor={{ - eventId: timeline.events[cursor]?.eventId, - index: cursor, - max: Math.max(0, timeline.events.length - 1), - }} - /> - ) : ( -

{worldError ?? "loading world…"}

+ } + map={( +
+
world map
+ {caption ?

{caption}

: null} + {world && selectedNode ? ( + { + const node = world.nodes.find((candidate) => candidate.id === id); + if (node) focusAndOpenPortal(node.scope); + }} + renderSettings={defaultRenderSettings} + roomPaths={world.roomPaths} + rooms={world.roomGeometries} + selectedNode={selectedNode} + selectedSkin={skin} + presenceByAgent={world.presenceByAgent} + spatialSamples={world.spatialSamples} + tick={presentationTick ?? variableTick} + tickDurationMs={world.tickDurationMs} + extensionData={world.viewerExtensionData} + extensionIdentities={world.viewerExtensionIdentities} + cursor={{ + eventId: timeline.events[cursor]?.eventId, + index: cursor, + max: Math.max(0, timeline.events.length - 1), + }} + /> + ) : ( +

{worldError ?? "loading world…"}

+ )} +
)} -
- -
- -
+ onSelect={setActivePanel} + /> {openPortals.map((ref, index) => ( diff --git a/web/src/viewer/replayPanel.test.ts b/web/src/viewer/replayPanel.test.ts new file mode 100644 index 0000000..6379d05 --- /dev/null +++ b/web/src/viewer/replayPanel.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { RunTimeline, TimelineEvent } from "../store/timeline.js"; +import { + defaultReplayPanel, + hasMeaningfulConversation, + initialReplayPanel, +} from "./replayPanel.js"; + +const event = (overrides: Partial): TimelineEvent => ({ + actor: "analyst", + authority: "moltnet", + causes: [], + eventId: "message-1", + payload: {}, + recordedAt: "2026-08-16T12:00:00.000Z", + seq: 1, + streamId: "room:dream_lab:consulting-room", + subjects: ["room:dream_lab:consulting-room"], + t: 1, + text: "A dream is spoken.", + type: "message.accepted", + viewClass: "message", + ...overrides, +}); + +const timeline = (events: TimelineEvent[]): RunTimeline => ({ + elements: [ + { kind: "agent", label: "Analyst", ref: "agent:analyst" }, + { kind: "room", label: "Consulting room", ref: "room:dream_lab:consulting-room" }, + ], + events, + runId: "jungian-dialogue", + version: "simfile.run-timeline.v1", +}); + +describe("replay panel selection", () => { + it("finds participant speech anywhere in the complete timeline", () => { + const run = timeline([ + event({ actor: undefined, eventId: "clock", t: 0, text: undefined, viewClass: "clock" }), + event({ eventId: "later-speech", t: 1 }), + ]); + assert.equal(hasMeaningfulConversation(run), true); + assert.equal(defaultReplayPanel(run), "conversation"); + }); + + it("falls back to map for world/control, undeclared, or blank messages", () => { + for (const candidate of [ + event({ actor: "world" }), + event({ actor: "undeclared" }), + event({ text: " " }), + event({ viewClass: "wake" }), + ]) { + assert.equal(defaultReplayPanel(timeline([candidate])), "map"); + } + }); + + it("prefers an explicit panel, then an existing user selection", () => { + const withSpeech = timeline([event({})]); + const withoutSpeech = timeline([]); + assert.equal(initialReplayPanel(withSpeech, "map", null), "map"); + assert.equal(initialReplayPanel(withoutSpeech, "conversation", null), "conversation"); + assert.equal(initialReplayPanel(withSpeech, undefined, "map"), "map"); + }); +}); diff --git a/web/src/viewer/replayPanel.ts b/web/src/viewer/replayPanel.ts new file mode 100644 index 0000000..ee3c5f2 --- /dev/null +++ b/web/src/viewer/replayPanel.ts @@ -0,0 +1,16 @@ +import { maxCursor, type ReplayPanel, type RunTimeline } from "../store/timeline.js"; +import { participantChatMessages } from "./ChatPane.js"; + +/** Uses the exact admission rule of the visible chat, over the complete run. */ +export const hasMeaningfulConversation = (timeline: RunTimeline): boolean => + participantChatMessages(timeline, maxCursor(timeline)).length > 0; + +export const defaultReplayPanel = (timeline: RunTimeline): ReplayPanel => + hasMeaningfulConversation(timeline) ? "conversation" : "map"; + +/** Explicit URL, then an existing user choice, then evidence-derived default. */ +export const initialReplayPanel = ( + timeline: RunTimeline, + explicitPanel: ReplayPanel | undefined, + existingPanel: ReplayPanel | null, +): ReplayPanel => explicitPanel ?? existingPanel ?? defaultReplayPanel(timeline); diff --git a/website/src/components/LandingWorldSection.astro b/website/src/components/LandingWorldSection.astro new file mode 100644 index 0000000..5c20854 --- /dev/null +++ b/website/src/components/LandingWorldSection.astro @@ -0,0 +1,71 @@ +
+
+
+ +

The whole world in one Simfile.

+

+ A single Simfile declares the clock, variables, generators, rules, markers, and falsifiable probes around your agents. The mechanics are deterministic for a seed; the agents still decide what to say, remember, and do. The world can wake an agent or post into a room; it never scripts a thought. +

+
+ +
+
+
[ Simfile ]one authored world
+
simfile_version: "0.1"
+name: autonomous-office-world
+spawnfile: ./Spawnfile            # the org of real agents this world wraps
+
+clock:                            # simulated time, deterministic for the seed
+  seed: office-run-014
+  tick: 20s
+
+variables:                        # world state you can drive and measure
+  filing_pressure:
+    scope: room:office-floor:case-warroom
+    initial: 0.4
+    range: 0..1
+
+generators:                       # how variables evolve each tick
+  deadline_ramp:
+    kind: deterministic
+    variable: filing_pressure
+    delta: 0.02
+
+rules:                            # when the world acts on the agents
+  deadline_bites:
+    when:
+      variable: filing_pressure
+      above: 0.85
+    do:
+      - action: moltnet:message   # observable world message, not a hidden prompt
+        to: room:office-floor:case-warroom
+        content: "The filing deadline is now urgent."
+
+markers:                          # tokens to trace as they spread
+  tenant_name:
+    text:
+      - Rosa Delgado
+    mode: containment
+    scopes:
+      - room:office-floor:case-warroom
+
+probes:                           # falsifiable checks over the run
+  deadline_observed:
+    when:
+      event: world.message
+      target: room:office-floor:case-warroom
+    expect:
+      at_least: 1
+
+ +
+
Run itshell
+
$ git clone https://github.com/noopolis/simfile.git
+$ cd simfile
+$ npm ci
+$ npm run example:local
+$ node dist/cli/index.js view <printed-run-directory>
+
+
+
+
diff --git a/website/src/content/docs/concepts.md b/website/src/content/docs/concepts.md index 93ddb73..d579124 100644 --- a/website/src/content/docs/concepts.md +++ b/website/src/content/docs/concepts.md @@ -5,7 +5,7 @@ description: The mental model behind deterministic worlds, sealed runs, causal o ## The world is deterministic; the society is not -A Simfile seed fixes the mechanical stream: clock ticks, stochastic draws, generator order, rule evaluation, and world effects. `simfile run` can reproduce that stream byte for byte from the same inputs. +A Simfile seed fixes the mechanical stream: clock ticks, stochastic draws, generator order, rule evaluation, and world effects. `simfile run --local --ticks ` can reproduce that stream byte for byte from the same inputs. Agents and external systems can still be nondeterministic. Simfile makes their outputs inspectable by sealing them as inputs to replay and observation. “Deterministic” describes the world kernel, not a promise that a model will say the same thing twice. @@ -19,7 +19,10 @@ This is the core authoring rule: hardcode constraints, not conclusions. ## One social transport -The world is a Moltnet participant. A `moltnet:message`, `moltnet:dm`, or `wake:recommend` action travels through the same room topology the organization already uses. There is no hidden prompt path that lets the experiment inject an answer directly into an agent. +The world is a Moltnet participant. A `moltnet:message` or `moltnet:dm` +action travels through the same room topology the organization already uses. +There is no hidden wake or prompt path that lets the experiment inject an +answer directly into an agent. That is especially important for memetics. A kickoff can ask Eleanor to discuss an office rollout, but it must not contain the seeded name. If the name appears, the evidence must come from the agent's recorded utterance or memory, not the instrument's own message. @@ -27,10 +30,15 @@ That is especially important for memetics. A kickoff can ask Eleanor to discuss The current package produces two related but distinct artifact shapes: -- a **kernel run** from `simfile run`, with `manifest.yaml`, canonical `ledger.jsonl`, telemetry, a kernel report, and `viewer-trace.json`; -- a **composed run** from a source driver, with `manifest.json` at `simfile.run-manifest.v1`, exported `raw/**/causal.jsonl` streams, transcripts, memory artifacts, and optional world telemetry. +- a **kernel run** from `simfile run --local --ticks `, with `manifest.yaml`, canonical `ledger.jsonl`, telemetry, a kernel report, and `viewer-trace.json`; +- a **composed run** from linked `simfile run `, with `manifest.json` at `simfile.run-manifest.v1`, exported `raw/**/causal.jsonl` streams, transcripts, memory artifacts, and optional world telemetry. -Kernel runs exercise world mechanics. Composed runs contain the cross-authority evidence needed by `simfile observe` and the full run-replay viewer. +Kernel runs exercise world mechanics. Linked composition delegates +organization lifecycle through Spawnfile and requires the project's binding +plus Simfile's `simfile.spawnfile-public-capability-probe.v1`, derived only +from documented generic Spawnfile CLI surfaces. Composed runs contain the +cross-authority evidence needed by `simfile observe` and the full run-replay +viewer. ## Causal order before wall time diff --git a/website/src/content/docs/guides/memetics.md b/website/src/content/docs/guides/memetics.md index 7912891..6bd7418 100644 --- a/website/src/content/docs/guides/memetics.md +++ b/website/src/content/docs/guides/memetics.md @@ -68,16 +68,20 @@ The replacement arm is positive evidence that the harness is responsive to the m ## Compose and seal a run -There is no packaged `simfile experiment`, `simfile compose`, or generic -source driver for this orchestration. Reproduction belongs in a fixture-owned -production runner that: +There is no packaged `simfile experiment` or `simfile compose` command. The +generic composition entrypoint is linked `simfile run `: it resolves +lifecycle to Spawnfile. It requires the project's composed binding, an +installed Spawnfile CLI, and the admitted Spawnfile 0.1.17 consumer-neutral +target contract; it is not a zero-config fixture runner. Simfile pins that +contract and an explicit local endpoint before lifecycle mutation. -1. delegates organization lifecycle to Spawnfile; -2. reads the seed agent's memory document and records a `seed_declaration`; -3. advances Simfile world mechanics at a fixed cadence; -4. accepts independently originated actions without waiting for agent replies; -5. exports authority artifacts before teardown; and -6. writes `manifest.json` last, sealing the run. +For a reproducible experiment, the linked project must also arrange to: + +1. read the seed agent's memory document and record a `seed_declaration`; +2. advance Simfile world mechanics at a fixed cadence; +3. accept independently originated actions without waiting for agent replies; +4. export authority artifacts before teardown; and +5. write `manifest.json` last, sealing the run. The captured seeded arm uses these fixture inputs: @@ -88,8 +92,9 @@ tokenSet: ["Rosa Delgado"] ``` Use a fresh deployment and output directory for every independent repetition. -The historical captured artifacts remain valid observer examples, but the -retired generic orchestration path is not a runnable reproduction command. +This fixture has the `spawnfile:` link but no committed composed project +binding, so the tree does not establish it as directly runnable through linked +composition. The historical captured artifacts remain valid observer examples. ## Observe each sealed run diff --git a/website/src/content/docs/guides/spawnfile-integration.md b/website/src/content/docs/guides/spawnfile-integration.md index d3b12a8..02f2712 100644 --- a/website/src/content/docs/guides/spawnfile-integration.md +++ b/website/src/content/docs/guides/spawnfile-integration.md @@ -13,7 +13,7 @@ A Simfile may point at the authored organization: spawnfile: ../org/Spawnfile ``` -That string is a source reference for authors and orchestration. `simfile validate` and `simfile run` do not parse it and do not start the organization. +That string is a source reference for authors and orchestration. `simfile validate` and local `simfile run --local` do not start the organization; linked `simfile run ` delegates lifecycle to Spawnfile. For binding validation, pass Spawnfile's machine-readable resolved graph explicitly: @@ -25,35 +25,106 @@ simfile validate ./world/Simfile \ The same check can run before a finite kernel trace: ```bash -simfile run ./world/Simfile \ +simfile run ./world/Simfile --local \ --ticks 144 \ --out runs/world-check \ --spawnfile-report .spawn/spawnfile-report.json ``` -The report lets Simfile verify referenced agents, teams, and rooms in variable and marker scopes, rule and probe event filters, and rule action targets. Passing the report still does not turn `simfile run` into an agent-backed composition. +The report lets Simfile verify referenced agents, teams, and rooms in variable and marker scopes, rule and probe event filters, and rule action targets. Passing the report still does not turn that local run into an agent-backed composition. ## The production composition boundary -Simfile does not ship a generic agent-orchestration driver. Production -composition is fixture-owned; the Tiny Football production runner is the -reference integration path. It delegates organization lifecycle to Spawnfile, -advances world mechanics at a fixed cadence, and admits independently -originated actions without waiting for cognition or conversation completion. +Linked `simfile run ` is the composition entrypoint. It delegates +organization lifecycle to Spawnfile and advances world mechanics independently +of cognition. It requires a compatible Spawnfile CLI and a project-owned +binding; it is not the mechanics-only quick start. + +### Standalone contributor setup + +Clone Spawnfile wherever you keep source checkouts, then give Simfile its +absolute path: + +```bash +cd /absolute/path/to/simfile +npm ci +npm run build +npm run dev:spawnfile:setup -- --source /absolute/path/to/spawnfile +npm run dev:spawnfile:check +``` + +Setup copies the selected Spawnfile checkout into a private temporary stage, +runs `npm ci`, builds and packs only that stage, and physically installs the +tarball beneath Simfile's ignored +`.simfile-dev/spawnfile/` root. The selected executable and Simfile's own +capability probe are recorded in `.simfile-dev/spawnfile/current.json`. There +is no `../spawnfile` convention, global link, or runtime import between +projects. + +The check validates +`examples/jungian-dialogue/org/Spawnfile` through that exact CLI, then +records `simfile.spawnfile-public-capability-probe.v1`. The probe reads only +generic documented surfaces: `--version`, `capabilities --json` when +available, and command `--help` as an older-release fallback. It never calls +`spawnfile compatibility --profile simfile.*` or requires Spawnfile to ship +Simfile-specific profiles. + +Do not proceed when the composed probe is not ready. Its missing or +unverifiable capabilities are product work, not values the operator should +guess. In particular, a target selector, base-image config digest, and private +helper command must not be copied from one developer's machine. + +The checked-in Jungian dialogue has one explicit composed runner: + +```bash +npm run example:composed -- --context +``` + +It runs only after the composed capability probe is ready. Simfile pins the +exact Spawnfile 0.1.17 43-command public contract and its executable identity. +The runner then calls the public target resolver to prove that the explicit +context is local before starting Simfile. Older, remote, default-selected, or +contract-drifted installations stop before lifecycle mutation. + +The invocation pins `--mode lifecycle-replay-smoke` and a unique run/output. +The analyst and daimon exchange a finite authored screenplay through a real +Spawnfile-managed Moltnet room after the analyst claims and observes the dream +world with its generated bearer token. Its +`simfile.composed-lifecycle-replay-smoke-receipt.v1` proves lifecycle +completion and exact replay, but reports strategic live agent-action evidence +as `not_evaluated`; transcript messages remain genuine exported engine output. +The default live run and its action-evidence verdict are unchanged. The old +one-agent regression is explicitly internal: + +```bash +npm run example:internal-smoke -- --context +``` + +Install a compatible release by exact coordinate: + +```bash +npm run dev:spawnfile:setup -- --package spawnfile@ +``` + +A prepacked release can be installed without registry resolution: + +```bash +npm run dev:spawnfile:setup -- --artifact /absolute/release.tgz --sha256 +``` Lifecycle composition uses these documented Spawnfile command families: ```bash -spawnfile up --detach --name --deployment --out --json -spawnfile artifacts export --deployment --compiled --out --json -spawnfile down --deployment --compiled --json +spawnfile lifecycle lookup +spawnfile up --detach --deployment --json --lifecycle-invocation ... +spawnfile artifacts export --out --json --lifecycle-invocation +spawnfile down --deployment --json --lifecycle-invocation ``` Between `up` and export, agents and the world communicate through declared -provider and action-ingress contracts. Export happens before teardown, and a -fixture runner seals its manifest only after all declared artifacts exist. -There is no public `simfile compose` command or generic package export for -agent-backed composition. +provider and action-ingress contracts. Export happens before teardown, and the +linked Simfile supervisor seals its manifest only after all declared artifacts exist. +There is no separate public `simfile compose` command. ## The artifact boundary @@ -77,10 +148,10 @@ Not every run has every optional artifact. The observer and viewer omit measurem ## Why Moltnet is the meeting point -World actions use the same rooms as agent actions: +World messages use the same rooms as agent messages: 1. Simfile advances the deterministic clock and evaluates rules. -2. A world message or wake recommendation is delivered through Moltnet. +2. A declared `moltnet:message` or `moltnet:dm` is delivered through Moltnet. 3. Spawnfile-managed agent bridges receive the room event. 4. Agents respond through their normal runtime and room connections. 5. Spawnfile exports each authority's causal and memory artifacts. @@ -90,7 +161,7 @@ This keeps the causal path observable. A hidden wake or prompt injection would m ## Recursive organizations -A fixture runner may copy the resolved Spawnfile report into the run. The +The linked Simfile supervisor may copy the resolved Spawnfile report into the run. The viewer uses its team nodes, representative bindings, and managed-network room plans to derive membranes. It can then show representatives and interior teams as one causally linked society. diff --git a/website/src/content/docs/guides/viewer.md b/website/src/content/docs/guides/viewer.md index b0dd2f1..5f5e0e5 100644 --- a/website/src/content/docs/guides/viewer.md +++ b/website/src/content/docs/guides/viewer.md @@ -21,11 +21,13 @@ simfile view --state .sim/ | Mode | How it is selected | What it shows | |---|---|---| -| Run-replay | `simfile view ` where `` contains a `simfile.run-manifest.v1` `manifest.json` and at least one Moltnet transcript | The full scrub timeline, world map, room chat, minds, storylines, measurements, and provenance | +| Run-replay | `simfile view ` where `` contains a `simfile.run-manifest.v1` `manifest.json` | The full scrub timeline, with optional chat/memory surfaces only when their artifacts exist | | Trace replay | `simfile view ` for another run-record shape | The world console over a sealed `manifest.yaml` plus `viewer-trace.json` | | Live | `simfile view --state ` | The world console over `/viewer-trace.json` with its current heartbeat/tick display | -Run-replay is selected from the directory shape, not by a flag. A transcript may be `raw/moltnet/transcript.json` or `raw/moltnet//transcript.json`. +Run-replay is selected from the manifest shape, not by a flag. A transcript is +optional; when present it may be `raw/moltnet/transcript.json` or +`raw/moltnet//transcript.json`. The current live surface is deliberately modest: it loads `viewer-trace.json` and runs a local heartbeat that loops through the trace's tick range. It does not yet tail a changing ledger or connect to Moltnet's live event stream. Use run-replay when you need the research instrument described below. @@ -37,7 +39,7 @@ Its inputs are: - `manifest.json`, including the declared artifacts and optional `seed_declaration`; - every `raw/**/causal.jsonl` stream; -- the Moltnet transcript or transcripts; +- optional Moltnet transcript or transcripts; - optional `raw/mneme//events.jsonl` memory logs; - optional `spawnfile-report.json` for nested team membranes; - optional `world/telemetry.json` for variable samples; @@ -52,7 +54,15 @@ The scrubber uses one dense event cursor across the entire run. Records are firs The global controls provide start, end, single-step, play or pause, and `0.5x`, `1x`, `2x`, `4x`, or `8x` playback. Playback is disabled when the browser requests reduced motion. When the run contains real `clock.sync` records, the scrubber also shows world ticks and phase bands. Seed-spread events appear as cyan dots only when their report event IDs join to actual timeline records. -## The three panes +## Primary views and minds + +Conversation and Map are selectable primary views. On the first load of a +sealed run, Conversation is selected only when the complete timeline contains +a nonblank message from a declared participant—the exact projection the chat +will show. World/control messages, undeclared actors, and blank records do not +trigger it. Map is the deterministic fallback. An explicit deep link or an +existing user choice wins and is not reset when the cursor moves or a live run +seals. ### World map @@ -112,19 +122,19 @@ The verdict strip summarizes turns, complete and incomplete chains, memory event ## Deep links -Run-replay serializes the current event, selection, and portal stack into the query string: +Run-replay serializes the current event, selection, portal stack, and primary panel into the query string: ```text -?at=&sel=&portals= +?at=&sel=&portals=&panel= ``` For example: ```text -?at=event-2&sel=agent%3Aeleanor&portals=room%3Anet%3Aroom%2Cbank%3Aoffice-recall +?at=event-2&sel=agent%3Aeleanor&portals=room%3Anet%3Aroom%2Cbank%3Aoffice-recall&panel=conversation ``` -`at` is a stable event ID, not the dense cursor index. When a run is re-derived, the link resolves that ID to its current causal position. Stale event IDs are ignored instead of crashing the viewer. The URL updates with `history.replaceState`, so scrubbing does not fill browser history. +`at` is a stable event ID, not the dense cursor index. When a run is re-derived, the link resolves that ID to its current causal position. Stale event IDs are ignored instead of crashing the viewer; an invalid explicit panel fails closed to Map. The URL updates with `history.replaceState`, so scrubbing does not fill browser history. The current link does not encode camera pose, lenses, operator tier, or run ID. A URL hash is preserved but has no viewer meaning. diff --git a/website/src/content/docs/introduction.md b/website/src/content/docs/introduction.md index 9c3ba61..2fb3f60 100644 --- a/website/src/content/docs/introduction.md +++ b/website/src/content/docs/introduction.md @@ -11,15 +11,24 @@ That distinction makes experiments falsifiable. The world mechanics are determin ## What has shipped -The package now covers the full instrument loop: +The package covers the local instrument loop and keeps linked composition +behind a fail-closed external compatibility gate: - `simfile validate` checks a world and can bind its agent, team, and room references against a Spawnfile compile report. -- `simfile run` executes a bounded deterministic kernel trace and writes a replayable run record. -- fixture-owned production runners compose the organization lifecycle around the Simfile world, export every authority's artifacts, and seal a `simfile.run-manifest.v1` directory; +- `simfile run --local --ticks ` executes a bounded deterministic kernel trace and writes a replayable run record. +- linked `simfile run ` is designed to delegate organization + lifecycle through compatible, generic Spawnfile CLI contracts, export every + authority's artifacts, and seal a `simfile.run-manifest.v1` directory; - `simfile observe` verifies those artifacts and reconciles causal streams without inventing missing links; - `simfile view` opens either the world replay or, for a composed run, the run-replay application with one timeline, a map, room chat, minds, storylines, memetic spread, and recursive mind portals. -There is no public `simfile compose` command yet. The distinction matters: CLI `run` is the finite world kernel; agent-backed composition belongs to fixture-owned production runners. The [quickstart](/quickstart/) shows both paths without pretending they are the same command. +There is no public `simfile compose` command. `--local --ticks` is the finite +world-kernel path. A linked `simfile run` is the separate composition +entrypoint. Its Spawnfile 0.1.17 integration pins the exact public capability +contract, executable identity, and explicit local target context, then uses +typed target/lifecycle reconciliation for recovery. Older or drifted releases +stop before mutation. The [quickstart](/quickstart/) shows both the local path +and the explicit composed compatibility gate. ## The first result @@ -37,7 +46,7 @@ The stack has three distinct responsibilities: - **Moltnet carries the rooms.** World messages and agent messages use the same social transport. - **Simfile authors and observes the world.** It supplies deterministic pressure and produces measurements from public artifacts. -Simfile does not compile Docker images, own runtime authentication, or deploy agents. Fixture-owned production runners use Spawnfile's documented lifecycle interface and versioned receipts; the viewer and observer consume sealed machine-readable artifacts rather than importing Spawnfile internals. +Simfile does not compile Docker images, own runtime authentication, or deploy agents. Linked composition uses Spawnfile's documented lifecycle interface and versioned receipts; the viewer and observer consume sealed machine-readable artifacts rather than importing Spawnfile internals. ## An instrument, not a screensaver diff --git a/website/src/content/docs/quickstart.md b/website/src/content/docs/quickstart.md index b550f09..b574ab0 100644 --- a/website/src/content/docs/quickstart.md +++ b/website/src/content/docs/quickstart.md @@ -3,14 +3,27 @@ title: Quickstart description: Validate, run, observe, and replay a Simfile with the commands that ship today. --- -Install Simfile with Node.js 22 or newer: +For the current source release, use the checkout's own built CLI with Node.js +>=22.19.0: ```bash -npm install --global simfile -simfile --help +git clone https://github.com/noopolis/simfile.git +cd simfile +npm ci +npm run build +npm run example:local ``` -Simfile has two run paths. `simfile run` executes the deterministic world kernel. A composed run also starts a Spawnfile organization and produces the cross-system artifact shape that `simfile observe` expects. The composed path is currently a repository API, not a public CLI subcommand. +The alias runs the canonical checked-in example with a unique run ID/output +and prints that directory. The examples below use `simfile` for readability. +In a source checkout, replace it with `node dist/cli/index.js`; do not rely on +an older global package with the same version string. + +Simfile has two run paths: a bounded local mechanics run, and linked +composition. Local mode is the working zero-service diagnostic. Linked +composition has a separate source-development setup and a machine-readable +readiness verdict; manual target environment variables are not a substitute +for that setup. ## 1. Write and validate a world @@ -37,14 +50,14 @@ If the world names Spawnfile agents, teams, or rooms, pass a machine-readable Sp simfile validate ./Simfile --spawnfile-report .spawn/spawnfile-report.json ``` -`--spawnfile-report` accepts either a path or inline JSON. The top-level `spawnfile:` value is an authored source reference; the CLI does not parse that file or start it implicitly. +`--spawnfile-report` accepts either a path or inline JSON. In local mode it supplies binding checks; linked composition uses the authored `spawnfile:` reference. ## 2. Run the deterministic kernel -`--ticks` is required. This command runs 144 ticks without sleeping and writes a sealed world run record: +`--local --ticks` is required. This command runs 144 ticks without sleeping and writes a sealed world run record: ```bash -simfile run ./Simfile \ +simfile run ./Simfile --local \ --ticks 144 \ --run-id tiny-001 \ --out runs/tiny-001 @@ -56,22 +69,69 @@ The directory contains `manifest.yaml`, `ledger.jsonl`, `report.json`, `telemetr simfile view runs/tiny-001 ``` -This is the correct path for testing clocks, variables, generators, rules, markers, probes, and queued world acts. It does **not** start the organization referenced by `spawnfile:`. +This is the correct path for testing clocks, variables, generators, rules, markers, probes, and optional local diagnostic inputs. It does not start an organization. ## 3. Compose a Spawnfile organization -Agent-backed composition is fixture-owned rather than a generic Simfile -package API. The Tiny Football production runner is the reference path: it -delegates lifecycle operations to Spawnfile, advances fixed-step world -mechanics during agent silence or inference, and accepts independently -originated actions through the world ingress contract. +For contributor work, install a separately checked-out Spawnfile into +Simfile's ignored tool root. The checkout can live anywhere: + +```bash +npm run dev:spawnfile:setup -- --source /absolute/path/to/spawnfile +npm run dev:spawnfile:check +``` + +This copies that exact checkout into a private stage, builds and packs only the +stage, installs it under `.simfile-dev/`, and validates the standalone +`examples/jungian-dialogue/org/Spawnfile` project through the isolated +Spawnfile CLI. It never imports a sibling repository or resolves a global +command. Once a compatible release is published, the equivalent setup is: + +```bash +npm run dev:spawnfile:setup -- --package spawnfile@ +``` + +For a prepacked release, use `--artifact /absolute/release.tgz --sha256 +` to pin the physical tarball without registry resolution. + +The check records Simfile's `simfile.spawnfile-public-capability-probe.v1`, +using only generic documented Spawnfile CLI surfaces: `--version`, +`capabilities --json` when available, and legacy help only as a fail-closed +fallback. It never calls `spawnfile compatibility --profile simfile.*`. Run a +linked project only when its composed result is ready; otherwise its blockers +are authoritative. This prevents a partially configured run from creating +Docker or support state and later failing during evidence export. + +After the composed probe reports ready, run the bounded Jungian dialogue with +one explicit local Docker context: + +```bash +npm run example:composed -- --context +``` -There is no `simfile compose` command. A fixture runner composes the lifecycle +The alias creates a unique run/output with `--mode lifecycle-replay-smoke`, +pins the exact installed Spawnfile 0.1.17 public contract, and proves the +explicit context is a local endpoint before starting the lifecycle. The +distinct `simfile.composed-lifecycle-replay-smoke-receipt.v1` requires the +lifecycle and exact replay to pass while live agent-action evidence is +explicitly `not_evaluated`. An analyst observes a three-symbol dream through +its authenticated world binding; then analyst and daimon produce a finite +five-message scripted dialogue through their real Moltnet room. The viewer +labels it as an authored screenplay and opens on Conversation because the +sealed run contains participant speech. +Older, remote, default-selected, or contract-drifted Spawnfile installations +fail closed before lifecycle mutation. + +Linked composition uses `simfile run ` with no `--ticks` or +`--spawnfile-report`. The project also needs a checked-in `world_sidecar` +binding and composer. + +There is no `simfile compose` command. The linked `simfile run ` supervisor composes the lifecycle around these authority boundaries: ```text spawnfile up --detach --name --deployment --out --json -→ fixture-owned fixed-step Simfile world + independent action ingress +→ Simfile world supervisor + independent action ingress → spawnfile artifacts export --deployment --compiled --out --json → spawnfile down --deployment --compiled --json → manifest.json written last @@ -81,7 +141,7 @@ The result is a `simfile.run-manifest.v1` directory with `raw/**/causal.jsonl`, ## 4. Observe the composed run -Run the observer only after the fixture runner has sealed the run directory: +Run the observer after linked composition has sealed the run directory: ```bash simfile observe runs/ @@ -109,7 +169,9 @@ The golden run is useful for learning the artifact and report shape. Its engine simfile view runs/ ``` -A composed run is detected from its `manifest.json` and Moltnet transcript and opens the full run-replay application. Add `--no-open` for a remote shell or choose a port explicitly: +A composed run is detected from its `simfile.run-manifest.v1` +`manifest.json`; a Moltnet transcript is optional. Add `--no-open` for a remote +shell or choose a port explicitly: ```bash simfile view runs/ --port 4400 --no-open diff --git a/website/src/content/docs/reference/cli.md b/website/src/content/docs/reference/cli.md index 92890e2..00b035b 100644 --- a/website/src/content/docs/reference/cli.md +++ b/website/src/content/docs/reference/cli.md @@ -3,18 +3,20 @@ title: CLI description: Exact commands and flags implemented by the Simfile v0.1 CLI. --- -The current command set is `validate`, `run`, `observe`, and `view`. +The current command set is `validate`, `run`, `observe`, `view`, and `recover`. ```text simfile validate [--json] [--spawnfile-report |] -simfile run --ticks [--out ] [--seed ] - [--run-id ] [--acts ] +simfile run [--view] [--out ] [--seed ] [--run-id ] +simfile run --local --ticks [--out ] [--seed ] + [--run-id ] [--acts ] [--clock ] [--moltnet-artifact transcript|delivery] [--spawnfile-report |] simfile observe [--json] simfile view --state simfile view simfile view --help +simfile recover --journal --run-id --authority-digest simfile --help ``` @@ -44,30 +46,71 @@ Validation performs strict structural checks and semantic checks such as declare Unknown flags, extra positional arguments, parse failures, or error-level binding diagnostics return exit status `1`. +## `recover` + +```bash +simfile recover --journal --run-id --authority-digest +``` + +`--journal` must be the normalized absolute path to the exact journal file in +the composed run's support directory (normally +`/journal/phase-journal.json`); it does not accept the support +directory itself. `--run-id` must match the journal's run ID, and +`--authority-digest` must be a `sha256:` digest that matches its authority. +These are the only options, in this exact order; there is no `--json` flag. + +Version-2 journals carry a secret-free bootstrap capsule that pins the exact +Spawnfile executable, capability contract, local context, paths, and public +project identities. Recovery reconstructs the resolver-backed provider from +that capsule, reconciles typed target and lifecycle lookups, and resumes only +after exact identity verification. An unresolved credential-provisioning +intent is reported as ambiguous and is never retried automatically. Legacy +journals without the capsule fail closed. Invalid syntax, an +unavailable or unsafe journal, and an authority mismatch also return `1` and +write the error to standard error. + ## `run` ```bash -simfile run ./Simfile --ticks 144 -simfile run ./Simfile --ticks 144 --run-id office-014 --out runs/office-014 -simfile run ./Simfile --ticks 144 --seed alternate-seed +simfile run ./Simfile --local --ticks 144 +simfile run ./Simfile --local --ticks 144 --run-id office-014 --out runs/office-014 +simfile run ./Simfile --local --ticks 144 --seed alternate-seed +simfile run ./Simfile --mode lifecycle-replay-smoke --out runs/composed-smoke ``` -`--ticks` is required and must be an integer greater than or equal to zero. The command validates first, then executes a bounded deterministic kernel trace without sleeping. +`--local --ticks` executes a bounded deterministic kernel trace without +sleeping. Linked `simfile run ` accepts neither `--ticks` nor +`--spawnfile-report`, and requires a project binding plus a Spawnfile CLI whose +generic public surfaces satisfy Simfile's +`simfile.spawnfile-public-capability-probe.v1`. For source development, install +and check that CLI with `npm run dev:spawnfile:setup` and `npm run +dev:spawnfile:check`; see [Spawnfile integration](/guides/spawnfile-integration/). +The source-checkout aliases are `npm run example:local` and `npm run +example:composed -- --context `. ### Run flags | Flag | Required | Meaning | | --- | --- | --- | -| `--ticks ` | yes | Number of kernel ticks. | +| `--ticks ` | local only | Number of kernel ticks. | +| `--mode ` | linked only | `live` (default) or the distinct `lifecycle-replay-smoke` evaluation. | | `--out ` | no | Output directory; defaults to `runs/`. | | `--seed ` | no | Effective seed; defaults to `clock.seed`. | | `--run-id ` | no | Run ID; defaults to a filesystem-safe form of the effective seed. | -| `--acts ` | no | JSON array of queued `variable:set` world acts. | -| `--moltnet-artifact ` | no | Add a harness-derived `transcript` or `delivery` artifact. | -| `--spawnfile-report ` | no | Add Spawnfile binding validation from a path or inline JSON. | +| `--acts ` | local only | JSON array of queued `variable:set` world acts. | +| `--clock ` | local only | Deterministic override for the wall-clock instant used to create the run record. | +| `--moltnet-artifact ` | local only | Add a harness-derived `transcript` or `delivery` artifact. | +| `--spawnfile-report ` | local only | Add Spawnfile binding validation from a path or inline JSON. | Every value flag also accepts `--flag=value`. +When the external lifecycle completes, `lifecycle-replay-smoke` +emits `simfile.composed-lifecycle-replay-smoke-receipt.v1`. It proves a +completed lifecycle and exact replay while declaring live agent-action +evidence `not_evaluated`; it never produces the strict live simulation +verdict. The development runner admits the exact Spawnfile 0.1.17 public +contract and proves the selected endpoint is local before starting the run. + The output directory contains: ```text @@ -82,9 +125,9 @@ moltnet-delivery.json optional The optional Moltnet files are explicitly marked `harness-derived`; they are not evidence captured from a live Moltnet service. Failed marker or probe evaluations are recorded in `report.json` but do not make the command itself fail. -`simfile run` does not start the file referenced by top-level `spawnfile:`. -Passing `--spawnfile-report` validates bindings only. Agent-backed composition -belongs to fixture-owned production runners; see +Local mode only uses `--spawnfile-report` for binding validation. Linked +composition starts the declared project through Spawnfile's public CLI only +after Simfile's public capability probe succeeds. See [Spawnfile integration](/guides/spawnfile-integration/). ### Queued world acts @@ -149,7 +192,7 @@ simfile view --help With a positional directory, the server selects replay behavior from the directory shape: -- `manifest.json` at `simfile.run-manifest.v1` plus a Moltnet transcript opens the full run-replay application; +- `manifest.json` at `simfile.run-manifest.v1` opens the full run-replay application; Moltnet transcripts are optional inputs; - `manifest.yaml` plus `viewer-trace.json` opens the world replay console. `--state` opens the live-labeled console over `viewer-trace.json`. That current surface reads a snapshot and serves a synthetic looping tick heartbeat; it is not yet a live tail of ledger or Moltnet events. @@ -158,4 +201,4 @@ The server runs until interrupted. Read [Viewer](/guides/viewer/) for the exact ## Help and exit behavior -`simfile --help` and `simfile -h` return success. Running `simfile` with no command prints usage and returns exit status `1`. Individual `validate`, `run`, and `observe` commands do not implement their own `--help`; `simfile view --help` does. +`simfile --help` and `simfile -h` return success. Running `simfile` with no command prints usage and returns exit status `1`. The usage overview includes `recover` and its required arguments. Individual `validate`, `run`, `observe`, and `recover` commands do not implement their own `--help`; `simfile view --help` does. diff --git a/website/src/content/docs/reference/simfile.md b/website/src/content/docs/reference/simfile.md index 5cf2e8c..6b1c86c 100644 --- a/website/src/content/docs/reference/simfile.md +++ b/website/src/content/docs/reference/simfile.md @@ -24,11 +24,17 @@ clock: | --- | --- | --- | | `simfile_version` | yes | Exactly `"0.1"`. | | `name` | yes | A Simfile identifier. | -| `spawnfile` | no | String source reference; not parsed or started by the CLI. | +| `spawnfile` | no | Project-relative Spawnfile link. Validation and explicit local runs retain it without resolving or starting Spawnfile; a default linked run resolves it and delegates lifecycle through Spawnfile when the project binding and operator prerequisites exist. | | `clock` | yes | Run seed, tick duration, optional simulation rate and phases. | +| `places` | no | Map of place ID to authored spatial metadata; defaults to `{}`. | +| `routes` | no | Map of route ID to connected place IDs; defaults to `{}`. | +| `presence` | no | Map of agent ID to its initial place; defaults to `{}`. | | `variables` | no | Map of variable ID to variable record; defaults to `{}`. | | `generators` | no | Map of generator ID to generator record; defaults to `{}`. | | `rules` | no | Map of rule ID to rule record; defaults to `{}`. | +| `world` | no | World identity and participant grants for an authored sidecar. | +| `world_sidecar` | no | Trusted project-relative binding and composer modules for linked composition. | +| `dynamics` | no | Project-relative deterministic dynamics provider and JSON configuration. | | `ledger` | no | Ledger store configuration. | | `telemetry` | no | Snapshot sampling configuration. | | `markers` | no | Map of marker ID to marker record; defaults to `{}`. | @@ -79,12 +85,12 @@ clock: | Field | Required | Meaning | | --- | --- | --- | -| `seed` | yes | Non-empty deterministic run seed. `simfile run --seed` can override it. | +| `seed` | yes | Non-empty deterministic run seed. `simfile run --local --ticks --seed` can override it. | | `tick` | yes | Positive duration of one kernel tick. | | `sim_per_tick` | no | Simulated duration advanced per tick; defaults to `tick`. | | `phases` | no | Map of phase ID to 24-hour `HH:MM`; defaults to `{}`. | -The finite `simfile run --ticks` loop does not sleep. `tick` becomes wall cadence only when a live driver chooses to wait between kernel steps. Phase selection repeats over a 24-hour simulated day. +The finite `simfile run --local --ticks ` loop does not sleep. `tick` becomes wall cadence only when a live driver chooses to wait between kernel steps. Phase selection repeats over a 24-hour simulated day. ## Variables @@ -133,7 +139,7 @@ mentions_of ticks_since_last_message ``` -These records validate today, but the finite `simfile run` batch runtime does not yet compute measured inputs. Do not treat a validated `measure` as a populated counter in that path. +These records validate today, but the finite `simfile run --local --ticks ` batch runtime does not yet compute measured inputs. Do not treat a validated `measure` as a populated counter in that path. ### Derived variables @@ -281,8 +287,9 @@ rules: variable: filing_pressure above: 0.85 do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "The filing deadline is now urgent." ``` | Field | Required | Meaning | @@ -304,9 +311,6 @@ Actions have exactly these shapes: to: agent: content: "A private world message." -- action: wake:recommend - to: room:: - - action: variable:set variable: value: 0.5 @@ -314,9 +318,16 @@ Actions have exactly these shapes: - action: variable:delta variable: value: 0.1 + +- action: move + agent: + to: ``` -Room messages require a room scope; DMs require an agent scope. Variable actions require a declared, non-fed variable. Braced placeholders in message content must name declared variables. +Room messages require a room scope; DMs require an agent scope. Variable +actions require a declared, non-fed variable. `move` requires a declared agent, +destination place, and a route from its current place. Braced placeholders in +message content must name declared variables. ## Markers @@ -387,7 +398,7 @@ ledger: `ledger.store` is required when `ledger` is present. `store.kind` is optional and defaults to `jsonl`; accepted values are `jsonl`, `sqlite`, and `postgres`. `store.path` is an optional non-empty string. -The current finite `simfile run` writer always emits `/ledger.jsonl`; it does not route that run record through the configured store kind or path. +The current finite `simfile run --local --ticks ` writer always emits `/ledger.jsonl`; it does not route that run record through the configured store kind or path. ## Telemetry @@ -396,7 +407,7 @@ telemetry: snapshot_every: 50 ``` -`snapshot_every` is an optional positive integer. When absent, `simfile run` writes every variable sample. When present, it keeps samples at ticks divisible by that value and also keeps the final sample. +`snapshot_every` is an optional positive integer. When absent, `simfile run --local --ticks ` writes every variable sample. When present, it keeps samples at ticks divisible by that value and also keeps the final sample. ## Spawnfile binding @@ -404,4 +415,4 @@ telemetry: simfile validate ./Simfile --spawnfile-report .spawn/spawnfile-report.json ``` -Without the report, scope strings are checked only for shape. With it, Simfile builds an index from Spawnfile report nodes and their active Moltnet room bindings. Binding checks cover variable scopes, marker scopes, rule and probe event filters, and rule action destinations. The current binding pass does not inspect `measure.scope`, pair members, or generator event filters. The report is an explicit validation input; the `spawnfile:` key never causes an implicit compile or deployment. +Without the report, scope strings are checked only for shape. With it, Simfile builds an index from Spawnfile report nodes and their active Moltnet room bindings. Binding checks cover variable scopes, marker scopes, rule and probe event filters, and rule action destinations. The current binding pass does not inspect `measure.scope`, pair members, or generator event filters. The report is an explicit validation input: `simfile validate` and `simfile run --local --ticks ` do not resolve, compile, or start the `spawnfile:` link. Default linked `simfile run ` is the separate path that resolves the link and delegates lifecycle through Spawnfile, subject to its required project binding and explicit operator prerequisites. diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 68702dd..b7cb4b0 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -1,5 +1,6 @@ --- import GlyphCity from '../components/GlyphCity.astro'; +import LandingWorldSection from '../components/LandingWorldSection.astro'; import SiteHeader from '../components/SiteHeader.astro'; import '../styles/landing.css'; --- @@ -48,9 +49,9 @@ import '../styles/landing.css'; Run your first experiment Inspect the experiment -
-
installshell
-
$ npm install simfile
+
+
source quickstartshell
+
$ git clone https://github.com/noopolis/simfile.git
@@ -59,7 +60,7 @@ import '../styles/landing.css';
@@ -76,76 +77,7 @@ import '../styles/landing.css';
-
-
-
- -

The whole world in one Simfile.

-

- A single Simfile declares the clock, variables, generators, rules, markers, and falsifiable probes around your agents. The mechanics are deterministic for a seed; the agents still decide what to say, remember, and do. The world can wake an agent or post into a room; it never scripts a thought. -

-
- -
-
-
[ Simfile ]one authored world
-
simfile_version: "0.1"
-name: autonomous-office-world
-spawnfile: ./Spawnfile            # the org of real agents this world wraps
-
-clock:                            # simulated time, deterministic for the seed
-  seed: office-run-014
-  tick: 20s
-
-variables:                        # world state you can drive and measure
-  filing_pressure:
-    scope: room:office-floor:case-warroom
-    initial: 0.4
-    range: 0..1
-
-generators:                       # how variables evolve each tick
-  deadline_ramp:
-    kind: deterministic
-    variable: filing_pressure
-    delta: 0.02
-
-rules:                            # when the world acts on the agents
-  deadline_bites:
-    when:
-      variable: filing_pressure
-      above: 0.85
-    do:
-      - action: wake:recommend    # nudge a room, never a scripted line
-        to: room:office-floor:case-warroom
-
-markers:                          # tokens to trace as they spread
-  tenant_name:
-    text:
-      - Rosa Delgado
-    mode: containment
-    scopes:
-      - room:office-floor:case-warroom
-
-probes:                           # falsifiable checks over the run
-  deadline_observed:
-    when:
-      event: wake.recommended
-      target: room:office-floor:case-warroom
-    expect:
-      at_least: 1
-
- -
-
Run itshell
-
$ npm install simfile
-$ simfile validate ./Simfile
-$ simfile run ./Simfile --out runs/latest
-$ simfile observe runs/latest        # causal chains + spread → report.json
-$ simfile view runs/latest           # scrub, descend, watch spread
-
-
-
-
+
@@ -153,7 +85,7 @@ probes: # falsifiable checks over the run

One timeline rewinds the whole society.

- Drag one cursor. The world map, the room conversation, every agent's memory, and the causal traces all return to the same moment. Every glyph points to a record. Ask anything which records make it true. The header always discloses whether the dialogue came from a real engine or a scripted fixture. + Drag one cursor. The world map, the room conversation, every agent's memory, and the causal traces all return to the same moment. A sealed run with participant speech opens on Conversation; Map remains one click away. Every glyph points to a record. Ask anything which records make it true. The header always discloses whether the dialogue came from a real engine or a scripted fixture.

@@ -200,11 +132,11 @@ probes: # falsifiable checks over the run

Run your own experiment.

quickstartshell
-
$ npm install simfile
-$ simfile validate ./Simfile
-$ simfile run ./Simfile --out runs/latest
-$ simfile observe runs/latest        # causal chains + spread → report.json
-$ simfile view runs/latest           # scrub, descend, watch spread
+
$ git clone https://github.com/noopolis/simfile.git
+$ cd simfile
+$ npm ci
+$ npm run example:local
+$ node dist/cli/index.js view <printed-run-directory>
Quickstart From e6e924e71b1d79fe9bcaa056bfb8612e404fe530 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 13:38:35 +0200 Subject: [PATCH 2/7] feat(observe): report per-agent and per-engine token usage from a sealed run --- src/observe/AGENTS.md | 15 +++ src/observe/compute.ts | 11 ++ src/observe/observe.ts | 4 +- src/observe/report.ts | 46 ++++++++ src/observe/usageLedger.test.ts | 168 ++++++++++++++++++++++++++++ src/observe/usageLedger.ts | 190 ++++++++++++++++++++++++++++++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 src/observe/usageLedger.test.ts create mode 100644 src/observe/usageLedger.ts diff --git a/src/observe/AGENTS.md b/src/observe/AGENTS.md index 3b24b72..baae749 100644 --- a/src/observe/AGENTS.md +++ b/src/observe/AGENTS.md @@ -33,6 +33,21 @@ Spawnfile internals; the only cross-repo dependency is the narrow shared package is visibly on the fallback, never silently. `recalls` is unaffected by which write source wins: it prefers `events.jsonl`'s own lines, falling back to causal `memory.recalled` events. +- `usageLedger.ts` — `collectUsage`: reads Daimon's per-turn engine usage + ledger out of Spawnfile's exported `raw/daimon/usage.jsonl` (plus the rotated + `usage.jsonl.1`, which is OLDER and is concatenated FIRST so an agent's engine + attribution comes from its earliest turn). Re-declares and re-validates the + `noopolis.daimon.turn-usage.v1` record with this repo's own zod parser rather + than importing Spawnfile's — a malformed line, a foreign `v`, or a torn final + line left by a crash mid-append is dropped, never coerced. Aggregates per + agent and per engine onto `usage`/`usage_summary`. Returns `undefined` when + the export carries NO ledger at all, so a codex-only organization (never + provisioned the volume) and an export taken before the first metered turn stay + distinct from an observed zero — the same absence-preserving convention + `worldGrants.ts` uses. Every total is a LOWER BOUND: `usage_summary.lower_bound` + is a schema literal `true` so no conformant report can present these counts as + exact, and `unknown_turns` carries the producing decoder's own + all-zero-is-unknown-not-free caveat across the repo hop. - `compute.ts` — pure functions building every `simfile.observe.v1` field from already-reconciled events: `participants` (from `principal_id`), `agent_turns` (ordered by the moltnet message seq that causally triggered each turn — never diff --git a/src/observe/compute.ts b/src/observe/compute.ts index ffd1023..46c405b 100644 --- a/src/observe/compute.ts +++ b/src/observe/compute.ts @@ -7,6 +7,7 @@ import type { SeedSpreadComputeResult } from "./seedSpread.js"; import { worldGrantsFromManifest } from "./worldGrants.js"; import type { WorldEvidence } from "./worldEvidence.js"; import type { SocialPlane } from "./socialPlane.js"; +import type { UsageObservation } from "./usageLedger.js"; const FAILURE_TYPES = new Set(["turn.failed", "wake.failed"]); const AGENT_PRINCIPAL_PATTERN = /^agent:(.+)$/u; @@ -107,6 +108,8 @@ export interface BuildObserveReportInput { allEvents: readonly CausalEvent[]; manifest: SimfileRunManifest; memoryBanks: readonly MemoryBankCounts[]; + /** Absent (not empty) when the sealed export carries no usage ledger at all. */ + usage?: UsageObservation; reconciled: ReconcileResult; /** Memetics increment (b): present only when `manifest.seed_declaration` * exists. `excluded` hits (instrument/operator actors) fold into @@ -132,6 +135,14 @@ export const buildObserveReport = (input: BuildObserveReportInput): SimfileObser memory_write_source: bank.memory_write_source, ...(bank.writes_by_agent ? { writes_by_agent: bank.writes_by_agent } : {}) })), + ...(input.usage === undefined ? {} : { + usage: input.usage.by_agent.map((agent) => ({ ...agent })), + usage_summary: { + lower_bound: true as const, + unknown_turns: input.usage.unknown_turns, + by_engine: input.usage.by_engine.map((engine) => ({ ...engine })) + } + }), failures: [ ...computeFailures(input.allEvents), ...(input.seedSpread?.excluded.map((excluded) => ({ event_id: excluded.event_id, reason: excluded.reason })) ?? []) diff --git a/src/observe/observe.ts b/src/observe/observe.ts index a64ccf7..d1f41c2 100644 --- a/src/observe/observe.ts +++ b/src/observe/observe.ts @@ -9,6 +9,7 @@ import type { CausalStreamSource } from "./causalStreams.js"; import { collectCausalStreams } from "./causalStreams.js"; import { buildObserveReport } from "./compute.js"; import { collectMemoryBankCounts } from "./memoryBanks.js"; +import { collectUsage } from "./usageLedger.js"; import type { SimfileRunManifest } from "./manifest.js"; import { parseRunManifest } from "./manifest.js"; import type { SimfileObserveReport } from "./report.js"; @@ -66,6 +67,7 @@ export const runObserve = async (runDir: string): Promise => { eventsByBank.set(bank, [...(eventsByBank.get(bank) ?? []), ...stream.events]); } const memoryBanks = await collectMemoryBankCounts(runDir, eventsByBank); + const usage = await collectUsage(runDir); let seedSpread: ReturnType | undefined; let spreadSelfCheck: SeedSpreadSelfCheck | undefined; @@ -97,7 +99,7 @@ export const runObserve = async (runDir: string): Promise => { const worldEvidenceResult = await readWorldEvidence(runDir, allEvents); const socialPlane = computeSocialPlane(await readSocialTranscript(runDir), allEvents); - const report = buildObserveReport({ allEvents, manifest, memoryBanks, reconciled, seedSpread, worldEvidence: worldEvidenceResult.evidence, socialPlane }); + const report = buildObserveReport({ allEvents, manifest, memoryBanks, reconciled, seedSpread, usage, worldEvidence: worldEvidenceResult.evidence, socialPlane }); return { artifactIntegrity, diff --git a/src/observe/report.ts b/src/observe/report.ts index def7b1a..1cd2969 100644 --- a/src/observe/report.ts +++ b/src/observe/report.ts @@ -77,6 +77,50 @@ const memoryBankEntrySchema = z }) .strict(); +/** + * Per-turn engine usage, derived from Spawnfile's exported Daimon ledger + * (`raw/daimon/usage.jsonl` + the rotated `.1`). + * + * `lower_bound` is a literal `true` on purpose: it is not a flag that could be + * `false`, it is a permanent property of this data, and encoding it in the + * schema means no conformant report can present these counts as exact. Neither + * metered engine's stream carries a completeness marker — both zero-fill a + * bucket they cannot account for — so a partially zero-filled turn sums to a + * plausible total and is indistinguishable from a real one. `unknown_turns` + * counts the turns the producing decoder itself flagged as unaccounted: + * unknown cost, never free. AGY reports no cost field at all, so a zero + * `notional_usd` likewise means unknown, not free. + * + * The whole `usage`/`usage_summary` pair is OMITTED when the export carries no + * ledger, which is distinct from a present-but-empty one — the same + * absence-preserving convention `world_grants` uses. + */ +const usageAgentEntrySchema = z + .object({ + agent: z.string().min(1), + engine: z.string().min(1), + turns: z.number().int().min(0), + tokens: z.number().min(0), + notional_usd: z.number().min(0), + unknown_turns: z.number().int().min(0) + }) + .strict(); + +const usageSummarySchema = z + .object({ + lower_bound: z.literal(true), + unknown_turns: z.number().int().min(0), + by_engine: z.array(z + .object({ + engine: z.string().min(1), + turns: z.number().int().min(0), + tokens: z.number().min(0), + notional_usd: z.number().min(0) + }) + .strict()) + }) + .strict(); + const failureEntrySchema = z .object({ reason: z.string().min(1), @@ -172,6 +216,8 @@ export const observeReportSchema = z }) .strict(), memory: z.array(memoryBankEntrySchema), + usage: z.array(usageAgentEntrySchema).optional(), + usage_summary: usageSummarySchema.optional(), failures: z.array(failureEntrySchema), seed_spread: z.array(seedSpreadSchema).optional(), spread_summary: spreadSummarySchema.optional(), diff --git a/src/observe/usageLedger.test.ts b/src/observe/usageLedger.test.ts new file mode 100644 index 0000000..5cf617b --- /dev/null +++ b/src/observe/usageLedger.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { parseObserveReport } from "./report.js"; +import { aggregateUsage, collectUsage, parseUsageLedgerLine } from "./usageLedger.js"; + +const line = (overrides: Record = {}): string => JSON.stringify({ + v: "noopolis.daimon.turn-usage.v1", + agent: "cogsworth", + wake: "wake-1", + engine: "grok", + at: "2026-08-29T12:00:00.000Z", + input: 100, + output: 20, + cache_read: 5, + cache_write: 0, + total: 125, + calls: 1, + notional_usd: 0.25, + complete: true, + ...overrides +}); + +/** Writes a sealed export tree. `null` omits that generation entirely. */ +const makeRunDir = async (generations: { current?: string | null; rotated?: string | null }): Promise => { + const runDir = await mkdtemp(path.join(tmpdir(), "simfile-usage-ledger-")); + const entries: [string, string | null | undefined][] = [ + ["usage.jsonl", generations.current], + ["usage.jsonl.1", generations.rotated] + ]; + if (entries.some(([, content]) => typeof content === "string")) { + await mkdir(path.join(runDir, "raw", "daimon"), { recursive: true }); + } + for (const [name, content] of entries) { + if (typeof content === "string") { + await writeFile(path.join(runDir, "raw", "daimon", name), content, "utf8"); + } + } + return runDir; +}; + +describe("parseUsageLedgerLine", () => { + it("accepts a well-formed record and rejects malformed ones rather than coercing", () => { + assert.equal(parseUsageLedgerLine(line())?.agent, "cogsworth"); + for (const [reason, bad] of [ + ["blank", " "], + ["not json", "{not json"], + ["wrong version", line({ v: "noopolis.daimon.turn-usage.v2" })], + ["empty agent", line({ agent: "" })], + ["unparseable date", line({ at: "not-a-date" })], + ["non-boolean complete", line({ complete: "yes" })], + ["negative total", line({ total: -1 })], + ["non-numeric total", line({ total: "125" })], + ["unknown key", JSON.stringify({ ...JSON.parse(line()) as object, surprise: 1 })] + ] as const) { + assert.equal(parseUsageLedgerLine(bad), null, reason); + } + }); + + it("skips a torn final line the way a crash mid-append leaves one", async () => { + const runDir = await makeRunDir({ current: `${line()}\n${line({ wake: "w2" }).slice(0, 40)}` }); + const usage = await collectUsage(runDir); + assert.equal(usage?.by_agent[0]?.turns, 1); + }); +}); + +describe("collectUsage", () => { + /** + * ROTATION: `usage.jsonl.1` is OLDER. Sums are order-independent, so the + * observable that actually pins the order is engine attribution — an agent's + * engine comes from its earliest record, so an agent that switched engines + * mid-run reports the one it started on. Reversing the generations produces a + * plausible-looking wrong answer, which is the whole hazard. + */ + it("reads the rotated generation before the current one", async () => { + const runDir = await makeRunDir({ + current: `${line({ engine: "agy", notional_usd: 0, wake: "newer" })}\n`, + rotated: `${line({ engine: "grok", wake: "older" })}\n` + }); + + const usage = await collectUsage(runDir); + + assert.equal(usage?.by_agent.length, 1); + assert.equal(usage?.by_agent[0]?.turns, 2); + assert.equal(usage?.by_agent[0]?.engine, "grok"); + assert.deepEqual(usage?.by_engine.map((entry) => entry.engine), ["agy", "grok"]); + }); + + it("splits totals across several agents and engines", async () => { + const runDir = await makeRunDir({ + current: [ + line({ agent: "cogsworth", engine: "grok", total: 100 }), + line({ agent: "foreman", engine: "agy", notional_usd: 0, total: 40 }), + line({ agent: "foreman", engine: "agy", notional_usd: 0, total: 60, wake: "w2" }) + ].join("\n") + "\n" + }); + + const usage = await collectUsage(runDir); + + assert.deepEqual(usage?.by_agent.map((entry) => [entry.agent, entry.turns, entry.tokens]), + [["cogsworth", 1, 100], ["foreman", 2, 100]]); + assert.deepEqual(usage?.by_engine.map((entry) => [entry.engine, entry.turns, entry.tokens]), + [["agy", 2, 100], ["grok", 1, 100]]); + }); + + /** + * ABSENT IS NOT ZERO. A codex-only organization is never provisioned the + * ledger volume, and an export may simply not carry one. `undefined` lets the + * caller omit the report field entirely rather than publish a confident zero — + * the same absence-preserving convention `world_grants` uses. + */ + it("reports an export with no ledger as absent, not as an empty observation", async () => { + assert.equal(await collectUsage(await makeRunDir({})), undefined); + }); + + it("reports a present but unusable ledger as an observed empty, distinct from absent", async () => { + const usage = await collectUsage(await makeRunDir({ current: "" })); + assert.notEqual(usage, undefined); + assert.deepEqual(usage?.by_agent, []); + assert.deepEqual(usage?.by_engine, []); + }); +}); + +describe("aggregateUsage lower-bound qualification", () => { + /** + * The ledger's decoders label an all-zero usage block UNKNOWN, not free. + * That caveat has to survive the hop into Simfile's report, or the report + * launders it into an exact-looking number. + */ + it("counts decoder-flagged turns as unknown rather than dropping them", () => { + const record = (overrides: Record = {}) => { + const parsed = parseUsageLedgerLine(line(overrides)); + assert.notEqual(parsed, null); + return parsed!; + }; + const usage = aggregateUsage([record({ complete: false }), record({ wake: "w2" })]); + assert.equal(usage.by_agent[0]?.turns, 2); + assert.equal(usage.by_agent[0]?.unknown_turns, 1); + assert.equal(usage.unknown_turns, 1); + }); + + it("is representable in simfile.observe.v1 only as a lower bound", () => { + const base = { + version: "simfile.observe.v1", + run_id: "run-1", + contract_versions: {}, + participants: [], + agent_turns: { count: 0, sequence: [] }, + chains: { complete: 0, incomplete: [] }, + memory: [], + failures: [], + usage: [{ agent: "cogsworth", engine: "grok", turns: 1, tokens: 125, notional_usd: 0.25, unknown_turns: 0 }], + usage_summary: { lower_bound: true, unknown_turns: 0, by_engine: [{ engine: "grok", turns: 1, tokens: 125, notional_usd: 0.25 }] } + }; + assert.equal(parseObserveReport(base).usage_summary?.lower_bound, true); + // A report claiming these counts are exact is not representable. + assert.throws( + () => parseObserveReport({ ...base, usage_summary: { ...base.usage_summary, lower_bound: false } }), + /invalid simfile\.observe\.v1/u + ); + // Absence stays valid and distinct from an empty observation. + const { usage: _usage, usage_summary: _summary, ...absent } = base; + assert.equal(parseObserveReport(absent).usage, undefined); + }); +}); diff --git a/src/observe/usageLedger.ts b/src/observe/usageLedger.ts new file mode 100644 index 0000000..0415983 --- /dev/null +++ b/src/observe/usageLedger.ts @@ -0,0 +1,190 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +/** + * Reads Daimon's per-turn engine usage ledger out of a SEALED run directory. + * + * Spawnfile's `artifacts export` egresses the ledger volume to + * `raw/daimon/usage.jsonl` (plus the rotated `raw/daimon/usage.jsonl.1`), so + * cost is observable from a torn-down run instead of only from a live + * container. This module reads those files the same way `memoryBanks.ts` reads + * `raw/mneme//**`: a plain file read of an exported machine-readable + * artifact. It imports nothing from Spawnfile — the wire record below is + * re-declared and re-validated here with Simfile's own zod parser, per the + * repository charter. + * + * Observer-tier: this only reads and counts. It never selects, wakes, invokes, + * or polls agent cognition. + */ + +/** The ledger record contract this reader accepts, by its own `v` string. */ +export const USAGE_TURN_RECORD_VERSION = "noopolis.daimon.turn-usage.v1" as const; + +/** Where the export lands the two generations, relative to the run directory. */ +const USAGE_RAW_DIRECTORY = path.join("raw", "daimon"); +const USAGE_CURRENT_FILE = "usage.jsonl"; +/** ROTATION: `.1` is the OLDER generation and must be read FIRST. */ +const USAGE_ROTATED_FILE = "usage.jsonl.1"; + +const nonNegative = z.number().refine( + (value) => Number.isFinite(value) && value >= 0, + "must be a finite non-negative number" +); + +/** + * Simfile's own validation of the wire record — deliberately not an import of + * Spawnfile's schema. Unknown keys are rejected rather than stripped, and every + * field is checked rather than coerced, so a malformed line is dropped instead + * of silently contributing a wrong number. + */ +const usageRecordSchema = z + .object({ + v: z.literal(USAGE_TURN_RECORD_VERSION), + agent: z.string().min(1), + wake: z.string().min(1), + engine: z.string().min(1), + at: z.string().min(1).refine((value) => !Number.isNaN(Date.parse(value)), "must be a date"), + input: nonNegative, + output: nonNegative, + cache_read: nonNegative, + cache_write: nonNegative, + total: nonNegative, + calls: nonNegative, + notional_usd: nonNegative, + complete: z.boolean() + }) + .strict(); + +export type UsageTurnRecord = z.infer; + +/** + * Parses one ledger line, returning `null` rather than throwing for anything + * unusable: a blank line, unparseable JSON, a different `v`, or a field that + * fails validation. A run that crashed mid-append leaves a torn final line, + * which fails `JSON.parse` and is skipped by exactly this path — the same + * treatment a garbled line gets. + */ +export const parseUsageLedgerLine = (line: string): UsageTurnRecord | null => { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + const result = usageRecordSchema.safeParse(parsed); + return result.success ? result.data : null; +}; + +export const parseUsageLedger = (content: string): UsageTurnRecord[] => + content.split(/\r?\n/u).flatMap((line) => { + const record = parseUsageLedgerLine(line); + return record === null ? [] : [record]; + }); + +export interface UsageAgentTotals { + agent: string; + /** The engine this agent opened the window on, from its earliest record. */ + engine: string; + turns: number; + tokens: number; + notional_usd: number; + /** Turns whose usage block was all zeros: UNKNOWN cost, never free. */ + unknown_turns: number; +} + +export interface UsageEngineTotals { + engine: string; + turns: number; + tokens: number; + notional_usd: number; +} + +export interface UsageObservation { + by_agent: UsageAgentTotals[]; + by_engine: UsageEngineTotals[]; + unknown_turns: number; +} + +const sumInto = ( + target: T, + record: UsageTurnRecord +): void => { + target.turns += 1; + target.tokens += record.total; + target.notional_usd += record.notional_usd; +}; + +/** + * Aggregates already-parsed records per agent and per engine. + * + * Records must arrive in chronological order, which is why the reader below + * concatenates the rotated generation first: an agent's `engine` is taken from + * the FIRST record seen, so an agent that changed engine mid-run reports the + * engine it started on rather than whichever generation happened to be read + * first. + * + * `unknown_turns` counts turns the producing decoder could not account for + * (`complete: false`). Those turns still consumed a subscription; they are + * surfaced so a reader can see that the totals below them are understated, + * never dropped and never rendered as free. + */ +export const aggregateUsage = (records: readonly UsageTurnRecord[]): UsageObservation => { + const byAgent = new Map(); + const byEngine = new Map(); + for (const record of records) { + const agent = byAgent.get(record.agent) + ?? { agent: record.agent, engine: record.engine, notional_usd: 0, tokens: 0, turns: 0, unknown_turns: 0 }; + sumInto(agent, record); + if (!record.complete) agent.unknown_turns += 1; + byAgent.set(record.agent, agent); + + const engine = byEngine.get(record.engine) + ?? { engine: record.engine, notional_usd: 0, tokens: 0, turns: 0 }; + sumInto(engine, record); + byEngine.set(record.engine, engine); + } + return { + by_agent: [...byAgent.values()].sort((left, right) => left.agent.localeCompare(right.agent)), + by_engine: [...byEngine.values()].sort((left, right) => left.engine.localeCompare(right.engine)), + unknown_turns: [...byAgent.values()].reduce((sum, agent) => sum + agent.unknown_turns, 0) + }; +}; + +const readGeneration = async (runDir: string, fileName: string): Promise => { + try { + return await readFile(path.join(runDir, USAGE_RAW_DIRECTORY, fileName), "utf8"); + } catch { + return null; + } +}; + +/** + * Reads both exported generations and aggregates them. + * + * Returns `undefined` — not an empty observation — when the export carries NO + * usage ledger at all. That distinction is the point: a codex-only organization + * is never provisioned the ledger volume and legitimately writes nothing, and an + * export taken before the first metered turn carries nothing either. Neither is + * evidence that the run was free, so the caller omits the report field entirely + * rather than publishing a zero. This mirrors how `worldGrants.ts` preserves + * absence as distinct from a declared none. + * + * A ledger that IS present but yields no usable records aggregates to an empty + * observation, which is a genuine, observed zero. + */ +export const collectUsage = async (runDir: string): Promise => { + // Rotated (older) FIRST, then current: chronological order across a rotation. + const [rotated, current] = await Promise.all([ + readGeneration(runDir, USAGE_ROTATED_FILE), + readGeneration(runDir, USAGE_CURRENT_FILE) + ]); + if (rotated === null && current === null) return undefined; + return aggregateUsage([ + ...parseUsageLedger(rotated ?? ""), + ...parseUsageLedger(current ?? "") + ]); +}; From d1061d1502c452a3007a0c411dac7b90a25f9d11 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:02:42 +0200 Subject: [PATCH 3/7] ci: bound every job with a timeout --- .github/workflows/deploy-website.yml | 2 ++ .github/workflows/publish.yml | 1 + .github/workflows/test.yml | 2 ++ 3 files changed, 5 insertions(+) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 8c607a4..2fcc094 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -19,6 +19,7 @@ concurrency: jobs: build: + timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -47,6 +48,7 @@ jobs: path: website/dist deploy: + timeout-minutes: 15 needs: build runs-on: ubuntu-latest environment: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 284ff70..120d10a 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/test.yml b/.github/workflows/test.yml index dae3031..e77f812 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ on: jobs: check: + timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -19,6 +20,7 @@ jobs: - run: npm run typecheck test: + timeout-minutes: 25 runs-on: ubuntu-latest strategy: fail-fast: false From 6a70613db9584eecbed1e7d97000614f4a28cb1a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:14:23 +0200 Subject: [PATCH 4/7] ci: split tests into named world/runtime and dynamics jobs instead of anonymous shards --- .github/workflows/test.yml | 40 +++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e77f812..0bf4d5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: branches: [main] jobs: - check: + typecheck-and-audit: timeout-minutes: 15 runs-on: ubuntu-latest steps: @@ -19,13 +19,14 @@ jobs: - run: npm audit --omit=dev --audit-level=high - run: npm run typecheck - test: + # Split by what the suite actually covers, not by arbitrary file shards. + # `src/dynamics` builds npm packages inside hermetic sandboxes: 16% of the + # tests but ~55% of the CPU, so it gets its own job. A red job then names the + # area that broke, which "test (3)" never could. + world-and-runtime: + name: world + runtime tests timeout-minutes: 25 runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -34,11 +35,24 @@ jobs: cache: npm - run: npm ci - run: npm run build - # The GitHub-hosted runner has 2 cores, so `node --test`'s file-level - # parallelism can't spread the CPU-heavy dynamics-build suites — the full - # suite runs ~20 min there despite ~2 min on an 18-core dev box. Shard the - # files across parallel jobs so wall time is the slowest shard, not the sum. - - run: >- + - name: Run every suite except the dynamics sandbox builds + run: >- node scripts/run-tests.mjs - --test-shard=${{ matrix.shard }}/4 - "src/**/*.test.ts" "web/src/**/*.test.ts" + $(find src web/src -name '*.test.ts' -not -path 'src/dynamics/*' | sort) + + dynamics-sandbox-builds: + name: dynamics sandbox builds + timeout-minutes: 25 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - name: Build and load dynamics packages in hermetic sandboxes + run: >- + node scripts/run-tests.mjs + $(find src/dynamics -name '*.test.ts' | sort) From 833d3821edccb82347225d1bd5f52b916a8afa4d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:20:01 +0200 Subject: [PATCH 5/7] ci: name every job after what it verifies --- .github/workflows/deploy-website.yml | 2 ++ .github/workflows/publish.yml | 1 + .github/workflows/test.yml | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 2fcc094..a91c074 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -19,6 +19,7 @@ concurrency: jobs: build: + name: build simfile.org timeout-minutes: 30 runs-on: ubuntu-latest steps: @@ -48,6 +49,7 @@ jobs: path: website/dist deploy: + name: deploy simfile.org timeout-minutes: 15 needs: build runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 120d10a..06f1c50 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,7 @@ concurrency: jobs: publish: + name: publish simfile to npm timeout-minutes: 20 runs-on: ubuntu-latest env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bf4d5c..8268895 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ on: jobs: typecheck-and-audit: + name: typecheck + dependency audit timeout-minutes: 15 runs-on: ubuntu-latest steps: From 74f2e0a59dfd8494a036194ffc654fd86a011be1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:23:49 +0200 Subject: [PATCH 6/7] test(view): wait for producer progress instead of asserting a tick rate --- src/view/serverRunLive.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/view/serverRunLive.test.ts b/src/view/serverRunLive.test.ts index 430ba2d..6951f91 100644 --- a/src/view/serverRunLive.test.ts +++ b/src/view/serverRunLive.test.ts @@ -209,13 +209,21 @@ describe("live dynamics run viewer", () => { const countSamplesMonotonically = createMonotonicSampleCounter(); const before = await waitForReadableSamples(stagingDir, path.join(project.directory, "run"), countSamplesMonotonically); const started = Date.now(); - await new Promise((resolve) => setTimeout(resolve, 2_100)); + // The property is that the producer keeps advancing with nothing consuming + // it — not that it hits a particular rate. Asserting ticks-per-second made + // this fail on a contended 2-core CI runner (before=0, during=0) while + // passing locally, which measured the hardware rather than the code. + let during = before; + const advanceDeadline = Date.now() + 30_000; + while (during - before <= 10 && Date.now() < advanceDeadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + during = await countSamplesMonotonically(stagingDir, path.join(project.directory, "run")); + } const elapsedSeconds = (Date.now() - started) / 1_000; - const during = await countSamplesMonotonically(stagingDir, path.join(project.directory, "run")); await run; const sealed = await readRunFrames(path.join(project.directory, "run")); assert.ok(during - before > 10, - `producer must advance many ticks without a consumer (before=${before}, during=${during})`); + `producer must advance without a consumer within 30s (before=${before}, during=${during})`); assert.ok(sealed); assert.equal(sealed.samples.length, ticks + 1); const noConsumerRate = (during - before) / elapsedSeconds; From 264c3e5e7450390aff6012c7c3d4b680d64279b6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 30 Aug 2026 15:28:17 +0200 Subject: [PATCH 7/7] docs: require every change to land through a pull request --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cc699ed..ed3fd01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,23 @@ checked out anywhere; never infer a sibling repository or import its source. - Keep `src/run/` as the timer-free local deterministic writer. Generic composed lifecycle code belongs in its own implementation folder and must be reused by any future `simfile dev` watch/debug wrapper. + +## Branches and pull requests + +**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.