From 6a1609e121a53e86a3bc63ae52563bcf54a5a98c Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 05:25:53 +0100 Subject: [PATCH 1/6] feat: separate host and projection selection --- .github/tests/test_detached_supervision.py | 16 +- README.md | 8 +- .../cmd/boatstack-helper/control_bundle.go | 174 ++++++++++--- .../cmd/boatstack-helper/declarative_flow.go | 2 +- .../cmd/boatstack-helper/flow_command.go | 92 +++++-- .../cmd/boatstack-helper/flow_runtime.go | 9 +- .../cmd/boatstack-helper/flow_runtime_test.go | 234 ++++++++++++++++-- .../boatstack-helper/human_identity_test.go | 2 +- .../cmd/boatstack-helper/input_command.go | 2 +- .../product_delivery_flow_e2e_test.go | 4 +- boatstack/controlprogram/artifact.go | 135 ++++++---- boatstack/controlprogram/canonical_test.go | 113 ++++++++- .../frontend_conformance_test.go | 3 +- boatstack/distribution/standard_test.go | 4 +- .../{skills.go => projections.go} | 47 ++-- .../{skills_test.go => projections_test.go} | 150 ++++++++++- boatstack/flow/standard/completeness_test.go | 1 + .../internal/hostprojection/projection.go | 232 +++++++++++++++++ .../hostprojection/projection_test.go | 104 ++++++++ boatstack/internal/runtime/control_bundle.go | 56 ++++- .../internal/runtime/control_bundle_test.go | 35 +++ boatstack/internal/runtime/flow_files.go | 23 ++ boatstack/internal/runtime/flow_files_test.go | 104 ++++++++ boatstack/internal/runtime/flow_ownership.go | 110 ++++++-- .../softwaredelivery/effects/artifacts.go | 22 +- .../effects/cas_integration_test.go | 4 +- .../effects/command_boundary_test.go | 2 +- .../{host_skills.go => host_projections.go} | 156 +++++++++--- ...kills_test.go => host_projections_test.go} | 125 ++++++++-- .../effects/integration_test.go | 18 +- .../softwaredelivery/effects/recovery_test.go | 2 +- .../humanidentitybinding/binding_test.go | 2 +- .../softwaredelivery/protocol/config.go | 27 +- .../softwaredelivery/protocol/config_test.go | 106 ++++++-- boatstack/references/config-schema.md | 2 +- boatstack/sdk/human_identity_test.go | 2 +- docs/configuration.md | 40 ++- docs/control-program-ir.md | 2 +- install.ps1 | 5 +- install.sh | 4 +- project.example.json | 8 +- .../2026-08-17-host-projection-selection.md | 9 + 42 files changed, 1876 insertions(+), 320 deletions(-) rename boatstack/flow/softwaredelivery/{skills.go => projections.go} (92%) rename boatstack/flow/softwaredelivery/{skills_test.go => projections_test.go} (68%) create mode 100644 boatstack/internal/hostprojection/projection.go create mode 100644 boatstack/internal/hostprojection/projection_test.go rename boatstack/internal/softwaredelivery/effects/{host_skills.go => host_projections.go} (56%) rename boatstack/internal/softwaredelivery/effects/{host_skills_test.go => host_projections_test.go} (52%) create mode 100644 release-notes/2026-08-17-host-projection-selection.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index fdd150f..339d2bc 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -175,11 +175,12 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No config.write_text( json.dumps( { - "schema_version": 3, + "schema_version": 4, "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": {"name": "fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"], + "projections": ["cursor", "codex", "claude", "gemini"], } ) ) @@ -193,13 +194,17 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No self.porcelain(), "\n".join( [ + "?? .agents/skills/boatstack-update/.gitattributes", "?? .agents/skills/boatstack-update/SKILL.md", "?? .agents/skills/boatstack-update/agents/openai.yaml", - "?? .boatstack/host-skills.json", + "?? .boatstack/host-projections.json", "?? .boatstack/project.json", "?? .boatstack/runtime.json", + "?? .claude/skills/boatstack-update/.gitattributes", "?? .claude/skills/boatstack-update/SKILL.md", + "?? .cursor/commands/.gitattributes", "?? .cursor/commands/boatstack-update.md", + "?? .gemini/skills/.gitattributes", "?? .gemini/skills/boatstack-update/SKILL.md", ] ), @@ -245,11 +250,12 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - config.write_text( json.dumps( { - "schema_version": 3, + "schema_version": 4, "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": {"name": "driver-fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "codex"], + "projections": ["codex"], } ) ) @@ -346,7 +352,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali config.write_text( json.dumps( { - "schema_version": 3, + "schema_version": 4, "identity": {"human": {"kind": "literal", "value": "contract"}}, "project": { "name": "retained-authority-fixture", @@ -355,11 +361,13 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali }, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "codex"], + "projections": ["codex"], } ) ) canonical_config = json.loads(config.read_text()) canonical_config["hosts"] = sorted(canonical_config["hosts"]) + canonical_config["projections"] = sorted(canonical_config["projections"]) canonical_config["policy"]["external_effect_authority"] = ( "human-or-autonomy-plus-provider" ) diff --git a/README.md b/README.md index 459ebe2..59dd59f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ language models, or coding-agent semantics. > [!WARNING] > Boatstack is alpha software for experimentation. The CLI, Control Program -> ABI, configuration schema, generated skills, and state format may change +> ABI, configuration schema, generated host projections, and state format may change > without a compatibility path. Audit it before using it on important work. During alpha development, Boatstack does not preserve backward compatibility. @@ -63,7 +63,7 @@ boatstack doctor --repo . --format text The installer verifies the latest release, pins that exact runtime to the repository, creates the initial configuration, and generates integrations for the enabled coding-agent hosts. Review and commit the generated -`.boatstack/` files and host skills before starting delivery. +`.boatstack/` files and host projections before starting delivery. Boatstack keeps runtime maintenance separate from repository delivery. The installer generates the maintenance skill: @@ -73,13 +73,13 @@ $boatstack-update # install a checksum-verified runtime update ``` A repository Flow declares its own entries. `boatstack flow compile` projects -those entries into host skills such as `$product-delivery-run`; Boatstack does +those entries into host-native projections such as `$product-delivery-run`; Boatstack does not interpret the word `run`. Compilation requires an explicitly selected, absolute frontend path and never executes an automatically discovered repository binary. If the agent was already running during installation, start a fresh task so it -can discover the generated skills. See [Getting started](docs/getting-started.md) +can discover the generated host projections. See [Getting started](docs/getting-started.md) for the lower-level CLI path and [Configuration](docs/configuration.md) for the repository policy schema. diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go index d5c0829..794e136 100644 --- a/boatstack/cmd/boatstack-helper/control_bundle.go +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -14,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -22,9 +23,11 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) -type hostSkillProjectionManifest struct { - SchemaVersion int `json:"schema_version"` - Files map[string]string `json:"files"` +type hostProjectionManifest struct { + SchemaVersion int `json:"schema_version"` + Projections []string `json:"projections"` + ProjectionSelectionFingerprint string `json:"projection_selection_fingerprint"` + Files map[string]string `json:"files"` } func buildRepositoryControlBundle(ctx context.Context, repository string) (boatstackruntime.ControlBundleSnapshot, error) { @@ -42,33 +45,53 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep } paths := map[string]struct{}{} absent := []string{} - if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "project.json")); statErr == nil { + var projectConfig *protocol.ProjectConfig + if info, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "project.json")); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: .boatstack/project.json is not a regular file") + } + raw, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if readErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } + config, decodeErr := protocol.DecodeProjectConfig(raw) + if decodeErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, decodeErr + } + projectConfig = &config paths[".boatstack/project.json"] = struct{}{} } else if os.IsNotExist(statErr) && allowMissingProject { absent = append(absent, ".boatstack/project.json") } else if statErr != nil { return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: .boatstack/project.json is required: %w", statErr) } + runtimeExists := false if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "runtime.json")); statErr == nil { + runtimeExists = true paths[".boatstack/runtime.json"] = struct{}{} } else if os.IsNotExist(statErr) { absent = append(absent, ".boatstack/runtime.json") } else if !os.IsNotExist(statErr) { return boatstackruntime.ControlBundleSnapshot{}, statErr } - manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") + manifestPath := filepath.Join(repository, ".boatstack", "host-projections.json") if raw, readErr := os.ReadFile(manifestPath); readErr == nil { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - var manifest hostSkillProjectionManifest - decodeErr := decoder.Decode(&manifest) - var trailing any - trailingErr := decoder.Decode(&trailing) - if decodeErr != nil || trailingErr != io.EOF || manifest.SchemaVersion != 1 || manifest.Files == nil { - return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host-skill manifest is malformed") - } - paths[".boatstack/host-skills.json"] = struct{}{} + manifest, decodeErr := decodeHostProjectionManifest(raw) + if decodeErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, decodeErr + } + if projectConfig == nil { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projections require project configuration") + } + selected, selectionErr := projectConfig.ProjectionIDs() + if selectionErr != nil || !sameProjectionIDs(manifest.Projections, hostprojection.Strings(selected)) { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_STALE: host projection manifest selection does not match project configuration") + } + paths[".boatstack/host-projections.json"] = struct{}{} for path, expected := range manifest.Files { + if !hostprojection.ValidMaintenancePath(path) { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: invalid host projection path %q", path) + } absolute, pathErr := exactRepositoryPath(repository, filepath.FromSlash(path)) if pathErr != nil { return boatstackruntime.ControlBundleSnapshot{}, pathErr @@ -79,14 +102,16 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep } digest := sha256.Sum256(raw) if hex.EncodeToString(digest[:]) != expected { - return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_STALE: host skill %s does not match its manifest", path) + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_STALE: host projection %s does not match its manifest", path) } paths[filepath.ToSlash(path)] = struct{}{} } } else if !os.IsNotExist(readErr) { return boatstackruntime.ControlBundleSnapshot{}, readErr + } else if projectConfig != nil && runtimeExists && !allowMissingProject { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: .boatstack/host-projections.json is required") } else { - absent = append(absent, ".boatstack/host-skills.json") + absent = append(absent, ".boatstack/host-projections.json") } artifacts, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ir.json")) if err != nil { @@ -107,7 +132,7 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep if loadErr != nil { return boatstackruntime.ControlBundleSnapshot{}, loadErr } - if _, checkErr := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills); checkErr != nil { + if _, checkErr := checkArtifactForCurrentProject(repository, artifact, resolver); checkErr != nil { return boatstackruntime.ControlBundleSnapshot{}, checkErr } relative, relErr := filepath.Rel(repository, artifactPath) @@ -122,7 +147,7 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep for path := range artifact.Assets { paths[path] = struct{}{} } - for path := range artifact.GeneratedSkills { + for path := range artifact.GeneratedProjections { paths[path] = struct{}{} } } @@ -147,6 +172,91 @@ func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, rep }}) } +func decodeHostProjectionManifest(raw []byte) (hostProjectionManifest, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var manifest hostProjectionManifest + if err := decoder.Decode(&manifest); err != nil { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projection manifest: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projection manifest contains trailing data") + } + if manifest.SchemaVersion != 2 || manifest.Projections == nil || manifest.Files == nil { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projection manifest is incomplete") + } + projections, err := hostprojection.ParseIDs(manifest.Projections) + if err != nil || !sameProjectionIDs(manifest.Projections, hostprojection.Strings(projections)) { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projection selection is not canonical") + } + fingerprint, err := hostprojection.SelectionFingerprint(projections) + if err != nil || fingerprint != manifest.ProjectionSelectionFingerprint { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host projection selection fingerprint mismatch") + } + for path, digest := range manifest.Files { + if !hostprojection.ValidMaintenancePath(path) || !hostprojection.ValidSHA256(digest) { + return hostProjectionManifest{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: invalid host projection binding") + } + } + return manifest, nil +} + +func sameProjectionIDs(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func replaceHostProjectionBundle(repository string, snapshot boatstackruntime.ControlBundleSnapshot, desired map[string][]byte, manifestRaw []byte) (boatstackruntime.ControlBundleSnapshot, error) { + projected := snapshot + manifestPath := filepath.Join(repository, ".boatstack", "host-projections.json") + if priorRaw, err := os.ReadFile(manifestPath); err == nil { + prior, decodeErr := decodeHostProjectionManifest(priorRaw) + if decodeErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, decodeErr + } + for path := range prior.Files { + if _, keep := desired[path]; keep { + continue + } + if hostprojection.IsSharedCheckoutPath(path) { + referenced, referenceErr := boatstackruntime.SharedFlowProjectionReferenced(repository, path, prior.Files[path]) + if referenceErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, referenceErr + } + if referenced { + continue + } + } + projected, decodeErr = boatstackruntime.ReplaceControlBundleFileAbsent(projected, path) + if decodeErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, decodeErr + } + } + } else if !os.IsNotExist(err) { + return boatstackruntime.ControlBundleSnapshot{}, err + } + var err error + projected, err = boatstackruntime.ReplaceControlBundleFile(projected, ".boatstack/host-projections.json", manifestRaw) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + for path, raw := range desired { + projected, err = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + } + return projected, nil +} + func controlBundleRequired(id catalog.TransitionID) bool { switch id { case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile", @@ -207,20 +317,18 @@ func bindControlBundle(ctx context.Context, repository string, transitionID cata if expected, exists := parameters.Get("config_sha256"); !exists || expected != configFingerprint { return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: project configuration fingerprint changed") } - hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + projections, projectionErr := config.ProjectionIDs() + if projectionErr != nil { + return nil, "", projectionErr + } + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostProjectionFiles(projections) if projectionErr != nil { return nil, "", projectionErr } - projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, ".boatstack/host-skills.json", manifestRaw) + projected, projectErr = replaceHostProjectionBundle(repository, projected, hostFiles, manifestRaw) if projectErr != nil { return nil, "", projectErr } - for path, raw := range hostFiles { - projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) - if projectErr != nil { - return nil, "", projectErr - } - } target = &projected case "installation.update", "installation.reconcile-update": configRaw, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) @@ -231,19 +339,17 @@ func bindControlBundle(ctx context.Context, repository string, transitionID cata if decodeErr != nil { return nil, "", decodeErr } - hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + projections, projectionErr := config.ProjectionIDs() if projectionErr != nil { return nil, "", projectionErr } - projected, projectionErr := boatstackruntime.ReplaceControlBundleFile(snapshot, ".boatstack/host-skills.json", manifestRaw) + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostProjectionFiles(projections) if projectionErr != nil { return nil, "", projectionErr } - for path, raw := range hostFiles { - projected, projectionErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) - if projectionErr != nil { - return nil, "", projectionErr - } + projected, projectionErr := replaceHostProjectionBundle(repository, snapshot, hostFiles, manifestRaw) + if projectionErr != nil { + return nil, "", projectionErr } target = &projected case "workspace.cut": diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index 4120140..d290cfd 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -101,7 +101,7 @@ func loadCurrentFlowArtifact(ctx context.Context, repository, programID string) if err != nil { return controlprogram.Compiled{}, err } - return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + return checkArtifactForCurrentProject(repository, artifact, resolver) } func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, options commandOptions) error { diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 9fe46fc..712f6f9 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -18,10 +18,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/core" "github.com/operatorstack/boatstack/boatstack/delivery" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -const flowCompilerVersion = "control-program.compiler.5" +const flowCompilerVersion = "control-program.compiler.6" type flowCommandOptions struct { repository string @@ -29,6 +31,7 @@ type flowCommandOptions struct { artifact string lock string frontend string + format string } func runFlowCommand(arguments []string) error { @@ -59,12 +62,16 @@ func runFlowCommand(arguments []string) error { flags.StringVar(&options.artifact, "artifact", "", "compiled Flow artifact path") flags.StringVar(&options.lock, "lock", "package-lock.json", "frontend dependency lock path") flags.StringVar(&options.frontend, "frontend", "", "exact boatstack-flow-frontend executable path") + flags.StringVar(&options.format, "format", "text", "output format: text or json") if err := flags.Parse(arguments[1:]); err != nil { return err } if flags.NArg() != 0 { return fmt.Errorf("unexpected flow arguments: %s", strings.Join(flags.Args(), " ")) } + if options.format != "text" && options.format != "json" { + return fmt.Errorf("unsupported flow output format %q", options.format) + } repository, err := filepath.Abs(options.repository) if err != nil { return err @@ -100,6 +107,10 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } + configPath, configRaw, _, projections, err := loadProjectProjectionSelection(options.repository) + if err != nil { + return err + } sourceRaw, err := os.ReadFile(source) if err != nil { return err @@ -118,6 +129,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err := requireUnchangedCompileInput(lockPath, lockRaw); err != nil { return err } + if err := requireUnchangedCompileInput(configPath, configRaw); err != nil { + return fmt.Errorf("FLOW_PROJECTION_SELECTION_STALE: %w", err) + } resolver, err := softwareflow.NewResolver(ctx) if err != nil { return err @@ -133,7 +147,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + generated, err := softwareflow.GenerateProjections(compiled, projections) if err != nil { return err } @@ -141,17 +155,18 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { lockRelative, _ := filepath.Rel(options.repository, lockPath) artifact, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: flowCompilerVersion, SourcePath: filepath.ToSlash(sourceRelative), Source: sourceRaw, - DependencyLockPath: filepath.ToSlash(lockRelative), DependencyLock: lockRaw, GeneratedSkills: skills, + DependencyLockPath: filepath.ToSlash(lockRelative), DependencyLock: lockRaw, + Projections: projections, GeneratedProjections: generated, }) if err != nil { return err } - removals, artifactPrevious, priorSkills, ownership, err := ownedProjectionChanges(options.repository, filepath.ToSlash(sourceRelative), artifactPath, artifact.GeneratedSkills) + removals, artifactPrevious, priorGenerated, ownership, err := ownedProjectionChanges(options.repository, filepath.ToSlash(sourceRelative), artifactPath, artifact.GeneratedProjections) if err != nil { return err } - paths := make([]string, 0, len(skills)) - for path := range skills { + paths := make([]string, 0, len(generated)) + for path := range generated { paths = append(paths, path) } sort.Strings(paths) @@ -162,7 +177,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { return pathErr } writes = append(writes, boatstackruntime.ProjectionWrite{ - Path: absolute, Content: skills[path], Mode: 0o644, ExpectedPreviousSHA256: priorSkills[path], + Path: absolute, Content: generated[path], Mode: 0o644, ExpectedPreviousSHA256: priorGenerated[path], }) } writes = append(writes, boatstackruntime.ProjectionWrite{ @@ -171,8 +186,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { expectations := []boatstackruntime.ProjectionExpectation{ {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, + {Path: configPath, Exists: true, ExpectedSHA256: fileDigest(configRaw)}, } - compileInputs := []string{source, lockPath} + compileInputs := []string{source, lockPath, configPath} assetPaths := make([]string, 0, len(artifact.Assets)) for relative := range artifact.Assets { assetPaths = append(assetPaths, relative) @@ -192,11 +208,11 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { return err } artifactRelative, _ := filepath.Rel(options.repository, artifactPath) - nextOwnership := boatstackruntime.NewFlowProjectionOwnership(filepath.ToSlash(sourceRelative), filepath.ToSlash(artifactRelative), artifactRaw, skills) + nextOwnership := boatstackruntime.NewFlowProjectionOwnership(filepath.ToSlash(sourceRelative), filepath.ToSlash(artifactRelative), artifactRaw, artifact.ProjectionSelectionFingerprint, generated) if err := boatstackruntime.ApplyOwnedFlowProjection(options.repository, writes, removals, expectations, ownership, nextOwnership); err != nil { return err } - return renderFlowResult("compiled", artifactPath, artifact) + return renderFlowResult("compiled", artifactPath, artifact, options.format) } func rejectProjectionInputOverlap(inputs []string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error { @@ -231,8 +247,8 @@ func ownedProjectionChanges(repository, sourceRelative, artifactPath string, nex return nil, "", map[string]string{}, ownership, err } prior := ownership.Record - retired := make([]boatstackruntime.ProjectionRemoval, 0, len(prior.GeneratedSkills)+1) - for relative, expected := range prior.GeneratedSkills { + retired := make([]boatstackruntime.ProjectionRemoval, 0, len(prior.GeneratedProjections)+1) + for relative, expected := range prior.GeneratedProjections { if _, retained := next[relative]; retained { continue } @@ -254,7 +270,7 @@ func ownedProjectionChanges(repository, sourceRelative, artifactPath string, nex retired = append(retired, boatstackruntime.ProjectionRemoval{Path: priorArtifact, ExpectedSHA256: prior.ArtifactSHA256, AllowMissing: true}) } sort.Slice(retired, func(i, j int) bool { return retired[i].Path < retired[j].Path }) - return retired, artifactPrevious, prior.GeneratedSkills, ownership, nil + return retired, artifactPrevious, prior.GeneratedProjections, ownership, nil } func fileDigest(value []byte) string { @@ -279,14 +295,14 @@ func checkFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + compiled, err := checkArtifactForCurrentProject(options.repository, artifact, resolver) if err != nil { return err } if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil { return err } - return renderFlowResult("valid", artifactPath, artifact) + return renderFlowResult("valid", artifactPath, artifact, options.format) } func validateCompiledFlow(ctx context.Context, repository string, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { @@ -586,13 +602,55 @@ func exactRepositoryPath(repository, relative string) (string, error) { return absolute, nil } -func renderFlowResult(status, artifactPath string, artifact controlprogram.Artifact) error { +func renderFlowResult(status, artifactPath string, artifact controlprogram.Artifact, format string) error { + generated := make([]string, 0, len(artifact.GeneratedProjections)) + for path := range artifact.GeneratedProjections { + generated = append(generated, path) + } + sort.Strings(generated) + if format != "json" { + selected := strings.Join(artifact.Projections, ",") + if selected == "" { + selected = "none" + } + _, err := fmt.Fprintf(os.Stdout, "Flow %s %s; projections: %s; generated: %d; artifact: %s\n", artifact.Program.Program.ID, status, selected, len(generated), artifactPath) + return err + } return json.NewEncoder(os.Stdout).Encode(map[string]any{ "status": status, "program_id": artifact.Program.Program.ID, "program_fingerprint": artifact.ProgramFingerprint, - "artifact": artifactPath, "entries": entryIDs(artifact.Program.Entries), + "artifact": artifactPath, "entries": entryIDs(artifact.Program.Entries), "projections": artifact.Projections, + "projection_selection_fingerprint": artifact.ProjectionSelectionFingerprint, "generated_paths": generated, }) } +func loadProjectProjectionSelection(repository string) (string, []byte, string, []hostprojection.ID, error) { + path, err := exactRepositoryPath(repository, filepath.Join(".boatstack", "project.json")) + if err != nil { + return "", nil, "", nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + return "", nil, "", nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err) + } + config, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return "", nil, "", nil, err + } + projections, err := config.ProjectionIDs() + if err != nil { + return "", nil, "", nil, err + } + return path, raw, fingerprint, projections, nil +} + +func checkArtifactForCurrentProject(repository string, artifact controlprogram.Artifact, resolver controlprogram.BindingResolver) (controlprogram.Compiled, error) { + _, _, _, projections, err := loadProjectProjectionSelection(repository) + if err != nil { + return controlprogram.Compiled{}, err + } + return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections) +} + func entryIDs(entries []controlprogram.Entry) []string { result := make([]string, len(entries)) for index, entry := range entries { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 1a737d3..1990695 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -16,6 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/core" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" @@ -126,7 +127,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err != nil { return commandOptions{}, err } - compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + compiled, err := checkArtifactForCurrentProject(repository, artifact, resolver) if err != nil { return commandOptions{}, err } @@ -1181,7 +1182,7 @@ func loadFlowDefinition(ctx context.Context, repository, programID string) (soft if err != nil { return softwareflow.Definition{}, err } - compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + compiled, err := checkArtifactForCurrentProject(repository, artifact, resolver) if err != nil { return softwareflow.Definition{}, err } @@ -1262,8 +1263,8 @@ func findEntry(entries []controlprogram.Entry, id string) (controlprogram.Entry, return controlprogram.Entry{}, false } -func generateSoftwareFlowSkills(compiled controlprogram.Compiled) (map[string][]byte, error) { - return softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) +func generateSoftwareFlowProjections(compiled controlprogram.Compiled, projections []hostprojection.ID) (map[string][]byte, error) { + return softwareflow.GenerateProjections(compiled, projections) } func flowRepositoryIdentity(repository string) (string, error) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 6dc2022..c88c3a6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "reflect" "runtime" + "sort" "strings" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" @@ -163,6 +165,58 @@ func TestTrustedControlBundleRejectsHeadDriftWithMatchingWorkingTree(t *testing. } } +func TestProjectionSelectionChangesProjectAndControlBundleNotProgram(t *testing.T) { + // control-law: projection-files-never-become-executable-control-authority + config := func(projections string) []byte { + return []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude","cursor","gemini"],"projections":` + projections + `}`) + } + codexRaw, allRaw := config(`["codex"]`), config(`["codex","claude","cursor","gemini"]`) + codexConfig, codexProjectFingerprint, err := protocol.ProjectConfigFingerprint(codexRaw) + if err != nil { + t.Fatal(err) + } + allConfig, allProjectFingerprint, err := protocol.ProjectConfigFingerprint(allRaw) + if err != nil { + t.Fatal(err) + } + if codexProjectFingerprint == allProjectFingerprint || !reflect.DeepEqual(codexConfig.ControlPolicy(), allConfig.ControlPolicy()) { + t.Fatal("projection membership did not remain nonsemantic to runtime policy") + } + bundleFor := func(raw []byte, config protocol.ProjectConfig) boatstackruntime.ControlBundleSnapshot { + projections, projectionErr := config.ProjectionIDs() + if projectionErr != nil { + t.Fatal(projectionErr) + } + files, manifest, projectionErr := effects.ProjectedHostProjectionFiles(projections) + if projectionErr != nil { + t.Fatal(projectionErr) + } + files[".boatstack/project.json"] = raw + files[".boatstack/host-projections.json"] = manifest + snapshot, snapshotErr := boatstackruntime.NewControlBundleSnapshot(files) + if snapshotErr != nil { + t.Fatal(snapshotErr) + } + return snapshot + } + codexBundle, allBundle := bundleFor(codexRaw, codexConfig), bundleFor(allRaw, allConfig) + if codexBundle.Fingerprint == allBundle.Fingerprint { + t.Fatal("projection membership did not change the control bundle fingerprint") + } + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + first, err := controlprogram.Compile(productDeliveryDocument("product-delivery"), resolver) + if err != nil { + t.Fatal(err) + } + second, err := controlprogram.Compile(productDeliveryDocument("product-delivery"), resolver) + if err != nil || first.Fingerprint != second.Fingerprint { + t.Fatalf("identical Flow semantics changed program fingerprint: %v", err) + } +} + func TestFlowEntryCanonicalizesRepositoryRoot(t *testing.T) { repository := flowRepository(t) writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) @@ -249,7 +303,7 @@ func TestFreshFlowInitializationRejectsDirtyCanonicalConfigurationBeforeEffects( runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) questionRaw, err := captureRunOutput(t, "flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--host", "codex", "--format", "json", @@ -296,7 +350,7 @@ func TestFreshFlowInitializationRejectsDirtyCanonicalConfigurationBeforeEffects( layout.StatePath, layout.ReceiptPath, filepath.Join(repository, ".boatstack", "runtime.json"), - filepath.Join(repository, ".boatstack", "host-skills.json"), + filepath.Join(repository, ".boatstack", "host-projections.json"), } { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("dirty initialization wrote %s: %v", path, statErr) @@ -327,6 +381,7 @@ func runFlowGitOutput(t *testing.T, repository string, arguments ...string) stri func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint string) { t.Helper() + writeMaintenanceProjectionFixture(t, repository) _, bundleFingerprint, err := bindControlBundle(context.Background(), repository, "", nil) if err != nil { t.Fatal(err) @@ -372,6 +427,7 @@ func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint func writeVerifiedFlowConfigurationState(t *testing.T, repository string) { t.Helper() + writeMaintenanceProjectionFixture(t, repository) resolver, err := plant.NewResolver("") if err != nil { t.Fatal(err) @@ -458,6 +514,42 @@ func TestCaptureStdoutDrainsWhileActionWrites(t *testing.T) { } } +func TestFlowResultExposesProjectionSelectionAndGeneratedPaths(t *testing.T) { + artifact := controlprogram.Artifact{ + ProgramFingerprint: strings.Repeat("a", 64), + Projections: []string{"claude", "codex"}, + ProjectionSelectionFingerprint: strings.Repeat("b", 64), + GeneratedProjections: map[string]string{ + ".claude/skills/example/SKILL.md": strings.Repeat("c", 64), + ".agents/skills/example/agents/openai.yaml": strings.Repeat("d", 64), + }, + Program: controlprogram.Document{Program: controlprogram.Program{ID: "example"}}, + } + raw, err := captureStdout(t, func() error { return renderFlowResult("valid", "/repo/example.flow.ir.json", artifact, "json") }) + if err != nil { + t.Fatal(err) + } + var rendered struct { + Projections []string `json:"projections"` + ProjectionSelectionFingerprint string `json:"projection_selection_fingerprint"` + GeneratedPaths []string `json:"generated_paths"` + } + if err := json.Unmarshal(raw, &rendered); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(rendered.Projections, artifact.Projections) || rendered.ProjectionSelectionFingerprint != artifact.ProjectionSelectionFingerprint { + t.Fatalf("selection output = %#v", rendered) + } + wantPaths := []string{".agents/skills/example/agents/openai.yaml", ".claude/skills/example/SKILL.md"} + if !reflect.DeepEqual(rendered.GeneratedPaths, wantPaths) { + t.Fatalf("generated paths = %v, want %v", rendered.GeneratedPaths, wantPaths) + } + textOutput, err := captureStdout(t, func() error { return renderFlowResult("valid", "/repo/example.flow.ir.json", artifact, "text") }) + if err != nil || !strings.Contains(string(textOutput), "projections: claude,codex") { + t.Fatalf("human output = %q, %v", textOutput, err) + } +} + func TestExplanationTextPreservesAuthorityAlgebra(t *testing.T) { response := surfaces.Response{ Operation: surfaces.OperationExplain, ProgramID: "product-delivery", EntryID: "run", RunID: "run-fixture", @@ -555,7 +647,7 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. t.Helper() projectPath := filepath.Join(repository, ".boatstack", "project.json") if _, err := os.Stat(projectPath); os.IsNotExist(err) { - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { @@ -565,7 +657,8 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. if err != nil { t.Fatal(err) } - skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + projections := []hostprojection.ID{hostprojection.Codex, hostprojection.Claude} + skills, err := softwareflow.GenerateProjections(compiled, projections) if err != nil { t.Fatal(err) } @@ -573,7 +666,7 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. writeFixture(t, repository, path, content) } _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ - CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, Projections: projections, GeneratedProjections: skills, }) if err != nil { t.Fatal(err) @@ -581,6 +674,45 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. writeFixture(t, repository, ".boatstack/flows/"+document.Program.ID+".flow.ir.json", artifactRaw) } +func writeProjectionProjectConfig(t *testing.T, repository string) { + t.Helper() + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) +} + +func writeMaintenanceProjectionFixture(t *testing.T, repository string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if err != nil { + t.Fatal(err) + } + config, err := protocol.DecodeProjectConfig(raw) + if err != nil { + t.Fatal(err) + } + projections, err := config.ProjectionIDs() + if err != nil { + t.Fatal(err) + } + files, manifest, err := effects.ProjectedHostProjectionFiles(projections) + if err != nil { + t.Fatal(err) + } + paths := make([]string, 0, len(files)+1) + for path, content := range files { + writeFixture(t, repository, path, content) + paths = append(paths, path) + } + writeFixture(t, repository, ".boatstack/host-projections.json", manifest) + paths = append(paths, ".boatstack/host-projections.json") + sort.Strings(paths) + arguments := append([]string{"add", "--"}, paths...) + runFlowGit(t, repository, arguments...) + if staged := runFlowGitOutput(t, repository, "diff", "--cached", "--name-only", "--"); staged != "" { + commitArguments := append([]string{"commit", "-q", "-m", "fixture maintenance projections", "--only", "--"}, paths...) + runFlowGit(t, repository, commitArguments...) + } +} + func productDeliveryDocument(programID string) controlprogram.Document { truth := true config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) @@ -1066,7 +1198,7 @@ func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { } var skillPath string - for path := range artifact.GeneratedSkills { + for path := range artifact.GeneratedProjections { skillPath = path break } @@ -1094,6 +1226,7 @@ func TestWorkspaceCutRejectsUncommittedRuntimePinBeforeEffect(t *testing.T) { runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + writeMaintenanceProjectionFixture(t, repository) pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin( boatstackruntime.Identity{Version: "v-test", SHA256: strings.Repeat("a", 64), SourceRevision: "test-revision"}, strings.Repeat("b", 64), durable.StateSchemaVersion, @@ -1189,7 +1322,7 @@ func TestOneStaleFlowBlocksMultiFlowControlBundle(t *testing.T) { if err != nil { t.Fatal(err) } - for path := range artifact.GeneratedSkills { + for path := range artifact.GeneratedProjections { writeFixture(t, repository, path, []byte("stale secondary projection\n")) if _, bundleErr := buildRepositoryControlBundle(context.Background(), repository); bundleErr == nil || !strings.Contains(bundleErr.Error(), path) { t.Fatalf("stale secondary Flow did not block complete bundle: %v", bundleErr) @@ -1453,6 +1586,7 @@ func TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{ repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, }) @@ -1464,6 +1598,41 @@ func TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { } } +func TestFlowCompileRejectsProjectSelectionChangedDuringFrontend(t *testing.T) { + // control-law: compile-publication-binds-the-exact-config-selection-read-before-rendering + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + writeProjectionProjectConfig(t, repository) + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("source")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + documentRaw, err := json.Marshal(productDeliveryDocument("product-delivery")) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, "raw-ir.json", documentRaw) + changed := `{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}` + writeFixture(t, repository, "changed-project.json", []byte(changed)) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncp '" + filepath.Join(repository, "changed-project.json") + "' '" + filepath.Join(repository, ".boatstack", "project.json") + "'\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{ + repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_SELECTION_STALE") { + t.Fatalf("config replacement result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json")); !os.IsNotExist(statErr) { + t.Fatalf("config race created an artifact: %v", statErr) + } +} + func TestFlowCompileDoesNotAutomaticallyExecuteRepositoryFrontend(t *testing.T) { // control-law: repository-content-cannot-authorize-ambient-frontend-execution repository, err := filepath.EvalSymlinks(t.TempDir()) @@ -1483,6 +1652,7 @@ func TestFlowCompileDoesNotAutomaticallyExecuteRepositoryFrontend(t *testing.T) if err := os.WriteFile(frontend, []byte("#!/bin/sh\nprintf executed > '"+sentinel+"'\n"), 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{repository: repository, lock: "package-lock.json"}) if err == nil || !strings.Contains(err.Error(), "FLOW_FRONTEND_REQUIRED") { t.Fatalf("automatic frontend result = %v", err) @@ -1514,6 +1684,7 @@ func TestFlowCompileNamesDefaultArtifactFromProgramID(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) if err := compileFlow(context.Background(), flowCommandOptions{ repository: repository, source: ".boatstack/flows/foo.flow.ts", lock: "package-lock.json", frontend: frontend, }); err != nil { @@ -1551,6 +1722,7 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) if err := compileFlow(context.Background(), flowCommandOptions{ repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, }); err != nil { @@ -1564,10 +1736,10 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { if err != nil { t.Fatal(err) } - if len(artifact.GeneratedSkills) != 5 { - t.Fatalf("generated skills = %v", artifact.GeneratedSkills) + if len(artifact.GeneratedProjections) != 5 { + t.Fatalf("generated projections = %v", artifact.GeneratedProjections) } - for path := range artifact.GeneratedSkills { + for path := range artifact.GeneratedProjections { if strings.Contains(path, "--") { t.Fatalf("artifact contains invalid skill path %s", path) } @@ -1598,6 +1770,7 @@ func TestFlowCompileRejectsDependencyLockProjectionOverlap(t *testing.T) { t.Fatal(err) } options := flowCommandOptions{repository: repository, source: sourcePath, lock: "package-lock.json", frontend: frontend} + writeProjectionProjectConfig(t, repository) if err := compileFlow(context.Background(), options); err != nil { t.Fatal(err) } @@ -1606,6 +1779,7 @@ func TestFlowCompileRejectsDependencyLockProjectionOverlap(t *testing.T) { t.Fatal(err) } options.lock = artifactPath + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), options) if err == nil || !strings.Contains(err.Error(), "FLOW_COMPILE_INPUT_OVERLAP") { t.Fatalf("overlapping lock result = %v", err) @@ -1643,6 +1817,7 @@ func TestFlowCompileRefusesUnmanagedGeneratedSkill(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{ repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, }) @@ -1678,24 +1853,26 @@ func TestFlowCompileRejectsForgedArtifactOwnership(t *testing.T) { writeFixture(t, repository, unrelatedPath, unrelated) resolver, _ := softwareflow.NewResolver(context.Background()) compiled, _ := controlprogram.Compile(document, resolver) - skills, _ := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + projections := []hostprojection.ID{hostprojection.Codex, hostprojection.Claude} + skills, _ := softwareflow.GenerateProjections(compiled, projections) for path, content := range skills { writeFixture(t, repository, path, content) } forged, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: flowCompilerVersion, SourcePath: ".boatstack/flows/product-delivery.flow.ts", Source: source, - DependencyLockPath: "package-lock.json", DependencyLock: lock, GeneratedSkills: skills, + DependencyLockPath: "package-lock.json", DependencyLock: lock, Projections: projections, GeneratedProjections: skills, }) if err != nil { t.Fatal(err) } - forged.GeneratedSkills[unrelatedPath] = fileDigest(unrelated) + forged.GeneratedProjections[unrelatedPath] = fileDigest(unrelated) forgedRaw, _ := json.Marshal(forged) writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ir.json", forgedRaw) frontend := filepath.Join(repository, "frontend.sh") if err := os.WriteFile(frontend, []byte("#!/bin/sh\ncat >/dev/null\ncat '"+filepath.Join(repository, "raw-ir.json")+"'\n"), 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend}) if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION") { t.Fatalf("forged ownership result = %v", err) @@ -1791,6 +1968,7 @@ func TestFlowCompileRetiresProjectionWhenSourceChangesProgramID(t *testing.T) { for _, programID := range []string{"foo", "bar"} { raw, _ := json.Marshal(productDeliveryDocument(programID)) writeFixture(t, repository, "raw-ir.json", raw) + writeProjectionProjectConfig(t, repository) if err := compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: "package-lock.json", frontend: frontend}); err != nil { t.Fatalf("compile %s: %v", programID, err) } @@ -1820,6 +1998,7 @@ func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { } sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" source, lock := []byte("declarative source"), []byte("lock") + writeProjectionProjectConfig(t, repository) writeFixture(t, repository, sourcePath, source) writeFixture(t, repository, lockPath, lock) if operation == "compile" { @@ -1833,6 +2012,7 @@ func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: lockPath, frontend: frontend}) if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { t.Fatalf("runtime-invalid compile result = %v", err) @@ -1850,7 +2030,8 @@ func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { if err != nil { t.Fatal(err) } - skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + projections := []hostprojection.ID{hostprojection.Codex, hostprojection.Claude} + skills, err := softwareflow.GenerateProjections(compiled, projections) if err != nil { t.Fatal(err) } @@ -1859,7 +2040,7 @@ func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { } _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, - DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + DependencyLockPath: lockPath, DependencyLock: lock, Projections: projections, GeneratedProjections: skills, }) if err != nil { t.Fatal(err) @@ -1888,6 +2069,7 @@ func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { } sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" source, lock := []byte("declarative source"), []byte("lock") + writeProjectionProjectConfig(t, repository) writeFixture(t, repository, sourcePath, source) writeFixture(t, repository, lockPath, lock) if operation == "compile" { @@ -1901,6 +2083,7 @@ func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { if err := os.WriteFile(frontend, script, 0o700); err != nil { t.Fatal(err) } + writeProjectionProjectConfig(t, repository) err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: lockPath, frontend: frontend}) if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { t.Fatalf("input-invalid compile result = %v", err) @@ -1918,7 +2101,8 @@ func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { if err != nil { t.Fatal(err) } - skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + projections := []hostprojection.ID{hostprojection.Codex, hostprojection.Claude} + skills, err := softwareflow.GenerateProjections(compiled, projections) if err != nil { t.Fatal(err) } @@ -1927,7 +2111,7 @@ func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { } _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, - DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + DependencyLockPath: lockPath, DependencyLock: lock, Projections: projections, GeneratedProjections: skills, }) if err != nil { t.Fatal(err) @@ -2443,7 +2627,7 @@ func TestFlowEntryRejectsPlanInboxSymlinkEscape(t *testing.T) { } } -func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { +func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedProjections(t *testing.T) { // control-law: removing-an-entry-cannot-leave-a-stale-authority-bearing-skill repository, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { @@ -2454,14 +2638,20 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { retired := []byte("retired") retainedPath := ".agents/skills/program-keep/SKILL.md" retiredPath := ".agents/skills/program-remove/SKILL.md" + selectionFingerprint, err := hostprojection.SelectionFingerprint([]hostprojection.ID{hostprojection.Codex}) + if err != nil { + t.Fatal(err) + } writeFixture(t, repository, retainedPath, retained) writeFixture(t, repository, retiredPath, retired) artifact := controlprogram.Artifact{ Schema: controlprogram.ArtifactSchemaName, SchemaRevision: controlprogram.ArtifactSchemaRevision, CompilerVersion: flowCompilerVersion, SourcePath: ".boatstack/flows/program.flow.ts", SourceSHA256: strings.Repeat("a", 64), DependencyLockPath: "package-lock.json", DependencyLockSHA256: strings.Repeat("b", 64), - ProgramFingerprint: strings.Repeat("c", 64), - GeneratedSkills: map[string]string{retainedPath: fileDigest(retained), retiredPath: fileDigest(retired)}, + ProgramFingerprint: strings.Repeat("c", 64), + Projections: []string{"codex"}, + ProjectionSelectionFingerprint: selectionFingerprint, + GeneratedProjections: map[string]string{retainedPath: fileDigest(retained), retiredPath: fileDigest(retired)}, } raw, err := json.Marshal(artifact) if err != nil { @@ -2474,7 +2664,7 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { if err != nil { t.Fatal(err) } - ownership := boatstackruntime.NewFlowProjectionOwnership(sourcePath, ".boatstack/flows/program.flow.ir.json", raw, map[string][]byte{retainedPath: retained, retiredPath: retired}) + ownership := boatstackruntime.NewFlowProjectionOwnership(sourcePath, ".boatstack/flows/program.flow.ir.json", raw, selectionFingerprint, map[string][]byte{retainedPath: retained, retiredPath: retired}) if err := boatstackruntime.ApplyOwnedFlowProjection(repository, []boatstackruntime.ProjectionWrite{ {Path: filepath.Join(repository, filepath.FromSlash(retainedPath)), Content: retained, Mode: 0o600}, {Path: filepath.Join(repository, filepath.FromSlash(retiredPath)), Content: retired, Mode: 0o600}, @@ -2490,7 +2680,7 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { t.Fatalf("retired paths = %v", paths) } if priorSkills[retainedPath] != fileDigest(retained) || priorSkills[retiredPath] != fileDigest(retired) { - t.Fatalf("prior generated skills = %v", priorSkills) + t.Fatalf("prior generated projections = %v", priorSkills) } writeFixture(t, repository, retiredPath, []byte("user changed")) paths, _, _, _, err = ownedProjectionChanges(repository, sourcePath, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 279e5ff..34dab01 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -35,7 +35,7 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) } - configRaw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + configRaw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) configPath := filepath.Join(repository, ".boatstack", "project.json") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go index 22446d5..0ec6ff3 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -184,7 +184,7 @@ func loadFlowInputContext(ctx context.Context, options flowInputOptions) (contro if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } - compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, bindingResolver, generateSoftwareFlowSkills) + compiled, err := checkArtifactForCurrentProject(options.repository, artifact, bindingResolver) if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index 8e96f0d..7c40694 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -57,7 +57,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T runFlowGit(t, repository, "init", "-q", "-b", "main") runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") runFlowGit(t, repository, "config", "user.name", "Fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) writeFixture(t, repository, ".boatstack/plans/inbox/todo.md", []byte("# Add one todo\n")) for path, content := range assets { writeFixture(t, repository, path, content) @@ -170,7 +170,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if workShowSuspension.Operation != surfaces.OperationWorkShow || workShowSuspension.CommitRequired == nil || workShowSuspension.CommitRequired.Code != controlBundleCommitRequiredCode { t.Fatalf("work show commit suspension = %#v", workShowSuspension) } - if status := runFlowGitOutput(t, repository, "status", "--short"); !strings.Contains(status, ".boatstack/runtime.json") || !strings.Contains(status, ".boatstack/host-skills.json") { + if status := runFlowGitOutput(t, repository, "status", "--short"); !strings.Contains(status, ".boatstack/runtime.json") || !strings.Contains(status, ".boatstack/host-projections.json") { t.Fatalf("automatic installation did not leave the exact bundle for explicit commit:\n%s", status) } runFlowGit(t, repository, "add", ".") diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index a666b23..234cd9e 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -11,57 +11,75 @@ import ( "path/filepath" "sort" "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 3 + ArtifactSchemaRevision = 4 ) type Artifact struct { - Schema string `json:"schema"` - SchemaRevision int `json:"schema_revision"` - CompilerVersion string `json:"compiler_version"` - SourcePath string `json:"source_path"` - SourceSHA256 string `json:"source_sha256"` - DependencyLockPath string `json:"dependency_lock_path"` - DependencyLockSHA256 string `json:"dependency_lock_sha256"` - ProgramFingerprint string `json:"program_fingerprint"` - GeneratedSkills map[string]string `json:"generated_skills"` - Assets map[string]string `json:"assets"` - Program Document `json:"program"` + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + CompilerVersion string `json:"compiler_version"` + SourcePath string `json:"source_path"` + SourceSHA256 string `json:"source_sha256"` + DependencyLockPath string `json:"dependency_lock_path"` + DependencyLockSHA256 string `json:"dependency_lock_sha256"` + ProgramFingerprint string `json:"program_fingerprint"` + Projections []string `json:"projections"` + ProjectionSelectionFingerprint string `json:"projection_selection_fingerprint"` + GeneratedProjections map[string]string `json:"generated_projections"` + Assets map[string]string `json:"assets"` + Program Document `json:"program"` } type ArtifactInput struct { - CompilerVersion string - SourcePath string - Source []byte - DependencyLockPath string - DependencyLock []byte - GeneratedSkills map[string][]byte + CompilerVersion string + SourcePath string + Source []byte + DependencyLockPath string + DependencyLock []byte + Projections []hostprojection.ID + GeneratedProjections map[string][]byte } // ProjectionGenerator derives repository projections from compiled executable // semantics. Artifact digests are evidence about this trusted derivation, not // an alternative authority for projection contents. -type ProjectionGenerator func(Compiled) (map[string][]byte, error) +type ProjectionGenerator func(Compiled, []hostprojection.ID) (map[string][]byte, error) func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, error) { if input.CompilerVersion == "" || !safeRelative(input.SourcePath) || !safeRelative(input.DependencyLockPath) { return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: compiler and relative source/lock paths are required") } - skills := make(map[string]string, len(input.GeneratedSkills)) - for path, raw := range input.GeneratedSkills { - if !safeGeneratedSkillPath(filepath.ToSlash(path)) { - return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill path %q", path) + projectionsIDs, err := hostprojection.ParseIDs(hostprojection.Strings(input.Projections)) + if err != nil { + return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: projection selection: %w", err) + } + selectionFingerprint, err := hostprojection.SelectionFingerprint(projectionsIDs) + if err != nil { + return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: projection selection: %w", err) + } + projections := hostprojection.Strings(projectionsIDs) + if projections == nil { + projections = []string{} + } + generated := make(map[string]string, len(input.GeneratedProjections)) + for path, raw := range input.GeneratedProjections { + if !hostprojection.ValidFlowPath(filepath.ToSlash(path)) { + return Artifact{}, nil, fmt.Errorf("FLOW_PROJECTION_PATH_INVALID: invalid generated projection path %q", path) } - skills[filepath.ToSlash(path)] = digest(raw) + generated[filepath.ToSlash(path)] = digest(raw) } artifact := Artifact{ Schema: ArtifactSchemaName, SchemaRevision: ArtifactSchemaRevision, CompilerVersion: input.CompilerVersion, SourcePath: filepath.ToSlash(input.SourcePath), SourceSHA256: digest(input.Source), DependencyLockPath: filepath.ToSlash(input.DependencyLockPath), DependencyLockSHA256: digest(input.DependencyLock), - ProgramFingerprint: compiled.Fingerprint, GeneratedSkills: skills, Assets: workAssetBindings(compiled.Document), Program: compiled.Document, + ProgramFingerprint: compiled.Fingerprint, Projections: projections, ProjectionSelectionFingerprint: selectionFingerprint, + GeneratedProjections: generated, Assets: workAssetBindings(compiled.Document), Program: compiled.Document, } encoded, err := json.MarshalIndent(artifact, "", " ") if err != nil { @@ -87,12 +105,20 @@ func LoadArtifact(source io.Reader) (Artifact, error) { if err := requireEOF(decoder); err != nil { return Artifact{}, err } - if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil || artifact.Assets == nil { + if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.Projections == nil || !hostprojection.ValidSHA256(artifact.ProjectionSelectionFingerprint) || artifact.GeneratedProjections == nil || artifact.Assets == nil { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: artifact envelope is incomplete") } - for path, fingerprint := range artifact.GeneratedSkills { - if !safeGeneratedSkillPath(path) || len(fingerprint) != 64 { - return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill binding") + projections, err := hostprojection.ParseIDs(artifact.Projections) + if err != nil || !sameProjectionStrings(artifact.Projections, hostprojection.Strings(projections)) { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: projection selection is not canonical") + } + selectionFingerprint, err := hostprojection.SelectionFingerprint(projections) + if err != nil || selectionFingerprint != artifact.ProjectionSelectionFingerprint { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: projection selection fingerprint mismatch") + } + for path, fingerprint := range artifact.GeneratedProjections { + if !hostprojection.ValidFlowPath(path) || !hostprojection.ValidSHA256(fingerprint) { + return Artifact{}, fmt.Errorf("FLOW_PROJECTION_PATH_INVALID: invalid generated projection binding") } } for path, fingerprint := range artifact.Assets { @@ -103,24 +129,7 @@ func LoadArtifact(source io.Reader) (Artifact, error) { return artifact, nil } -func safeGeneratedSkillPath(value string) bool { - if !safeRelative(value) { - return false - } - parts := strings.Split(value, "/") - if len(parts) == 4 && (parts[0] == ".agents" || parts[0] == ".claude") && parts[1] == "skills" && validID(parts[2]) && parts[3] == ".gitattributes" { - return true - } - if len(parts) == 4 && parts[0] == ".agents" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "SKILL.md" { - return true - } - if len(parts) == 5 && parts[0] == ".agents" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "agents" && parts[4] == "openai.yaml" { - return true - } - return len(parts) == 4 && parts[0] == ".claude" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "SKILL.md" -} - -func CheckArtifact(repository string, artifact Artifact, compilerVersion string, resolver BindingResolver, generate ProjectionGenerator) (Compiled, error) { +func CheckArtifact(repository string, artifact Artifact, compilerVersion string, resolver BindingResolver, expectedProjections []hostprojection.ID, generate ProjectionGenerator) (Compiled, error) { if artifact.CompilerVersion != compilerVersion { return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: compiler version changed") } @@ -156,14 +165,22 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: program fingerprint does not match artifact") } expected := map[string][]byte{} + canonicalExpected, err := hostprojection.ParseIDs(hostprojection.Strings(expectedProjections)) + if err != nil { + return Compiled{}, fmt.Errorf("FLOW_PROJECTION_SELECTION_STALE: expected projection selection is invalid") + } + wantSelectionFingerprint, err := hostprojection.SelectionFingerprint(canonicalExpected) + if err != nil || wantSelectionFingerprint != artifact.ProjectionSelectionFingerprint || !sameProjectionStrings(artifact.Projections, hostprojection.Strings(canonicalExpected)) { + return Compiled{}, fmt.Errorf("FLOW_PROJECTION_SELECTION_STALE: artifact projection selection does not match project configuration") + } if generate != nil { - expected, err = generate(compiled) + expected, err = generate(compiled, canonicalExpected) if err != nil { return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: regenerate projections: %w", err) } } - if len(expected) != len(artifact.GeneratedSkills) { - return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill set does not match compiled program") + if len(expected) != len(artifact.GeneratedProjections) { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated projection set does not match compiled program") } paths := make([]string, 0, len(expected)) for path := range expected { @@ -171,17 +188,29 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, } sort.Strings(paths) for _, path := range paths { - if !safeGeneratedSkillPath(path) || artifact.GeneratedSkills[path] != digest(expected[path]) { - return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s is not derived from compiled program", path) + if !hostprojection.ValidFlowPath(path) || artifact.GeneratedProjections[path] != digest(expected[path]) { + return Compiled{}, fmt.Errorf("FLOW_PROJECTION_PATH_INVALID: generated projection %s is not derived from compiled program", path) } raw, readErr := readRepositoryFile(repository, path) if readErr != nil || !bytes.Equal(raw, expected[path]) { - return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s does not match compiled program", path) + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated projection %s does not match compiled program", path) } } return compiled, nil } +func sameProjectionStrings(one, two []string) bool { + if len(one) != len(two) { + return false + } + for index := range one { + if one[index] != two[index] { + return false + } + } + return true +} + func workAssetBindings(document Document) map[string]string { result := map[string]string{} for _, contract := range document.Work { diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 98bc98f..a2a4c08 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -7,10 +7,12 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) type delegationResolver struct { @@ -644,7 +646,7 @@ func TestStrictLoadersRejectOversizedTrailingInput(t *testing.T) { } _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("source"), - DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), GeneratedSkills: map[string][]byte{}, + DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), Projections: []hostprojection.ID{}, GeneratedProjections: map[string][]byte{}, }) if err != nil { t.Fatal(err) @@ -682,7 +684,7 @@ func TestCompilerRejectsUndeclaredEffectAndMissingRecovery(t *testing.T) { } } -func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { +func TestArtifactBindsSourceLockProjectionsAndCompiler(t *testing.T) { // control-law: runtime-admits-only-an-exact-source-lock-artifact-projection repository := t.TempDir() sourcePath, lockPath, skillPath := "flow.ts", "package-lock.json", ".agents/skills/respond/SKILL.md" @@ -702,21 +704,21 @@ func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { } artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: "compiler-1", SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, - GeneratedSkills: map[string][]byte{skillPath: skill}, + Projections: []hostprojection.ID{hostprojection.Codex}, GeneratedProjections: map[string][]byte{skillPath: skill}, }) if err != nil { t.Fatal(err) } - generate := func(controlprogram.Compiled) (map[string][]byte, error) { + generate := func(controlprogram.Compiled, []hostprojection.ID) (map[string][]byte, error) { return map[string][]byte{skillPath: skill}, nil } - if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, generate); err != nil { + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, []hostprojection.ID{hostprojection.Codex}, generate); err != nil { t.Fatal(err) } forgedSkill := []byte("forged skill") forgedArtifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: "compiler-1", SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, - GeneratedSkills: map[string][]byte{skillPath: forgedSkill}, + Projections: []hostprojection.ID{hostprojection.Codex}, GeneratedProjections: map[string][]byte{skillPath: forgedSkill}, }) if err != nil { t.Fatal(err) @@ -724,7 +726,7 @@ func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), forgedSkill, 0o600); err != nil { t.Fatal(err) } - if _, err := controlprogram.CheckArtifact(repository, forgedArtifact, "compiler-1", nil, generate); err == nil || !strings.Contains(err.Error(), "derived from compiled program") { + if _, err := controlprogram.CheckArtifact(repository, forgedArtifact, "compiler-1", nil, []hostprojection.ID{hostprojection.Codex}, generate); err == nil || !strings.Contains(err.Error(), "derived from compiled program") { t.Fatalf("self-consistent forged projection result = %v", err) } if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), skill, 0o600); err != nil { @@ -733,12 +735,97 @@ func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { if err := os.WriteFile(filepath.Join(repository, sourcePath), []byte("changed"), 0o600); err != nil { t.Fatal(err) } - if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, generate); err == nil || !strings.Contains(err.Error(), "source") { + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, []hostprojection.ID{hostprojection.Codex}, generate); err == nil || !strings.Contains(err.Error(), "source") { t.Fatalf("stale source result = %v", err) } } -func TestArtifactRejectsGeneratedPathsOutsideHostSkillRoots(t *testing.T) { +func TestProjectionSelectionChangesArtifactEnvelopeButNotControlProgram(t *testing.T) { + // control-law: generated-file-selection-is-nonsemantic-to-the-control-program + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + base := controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("same source"), + DependencyLockPath: "package-lock.json", DependencyLock: []byte("same lock"), + } + codex := base + codex.Projections = []hostprojection.ID{hostprojection.Codex} + codex.GeneratedProjections = map[string][]byte{".agents/skills/respond/SKILL.md": []byte("codex")} + all := base + all.Projections = hostprojection.CanonicalIDs() + all.GeneratedProjections = map[string][]byte{ + ".agents/skills/respond/SKILL.md": []byte("codex"), + ".claude/skills/respond/SKILL.md": []byte("claude"), + ".cursor/commands/respond.md": []byte("cursor"), + ".gemini/skills/respond/SKILL.md": []byte("gemini"), + } + codexArtifact, codexRaw, err := controlprogram.NewArtifact(compiled, codex) + if err != nil { + t.Fatal(err) + } + allArtifact, allRaw, err := controlprogram.NewArtifact(compiled, all) + if err != nil { + t.Fatal(err) + } + if codexArtifact.ProgramFingerprint != compiled.Fingerprint || allArtifact.ProgramFingerprint != compiled.Fingerprint { + t.Fatal("projection selection changed the Control Program fingerprint") + } + if codexArtifact.ProjectionSelectionFingerprint == allArtifact.ProjectionSelectionFingerprint { + t.Fatal("different projection memberships shared a selection fingerprint") + } + codexEnvelope, allEnvelope := sha256.Sum256(codexRaw), sha256.Sum256(allRaw) + if codexEnvelope == allEnvelope { + t.Fatal("different projection memberships shared an artifact-envelope fingerprint") + } +} + +func TestArtifactCheckCanonicalizesExpectedProjectionOrder(t *testing.T) { + repository := t.TempDir() + files := map[string][]byte{ + "flow.ts": []byte("source"), + "package-lock.json": []byte("lock"), + ".agents/skills/respond/SKILL.md": []byte("codex"), + ".claude/skills/respond/SKILL.md": []byte("claude"), + } + for path, raw := range files { + absolute := filepath.Join(repository, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absolute, raw, 0o600); err != nil { + t.Fatal(err) + } + } + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + generated := map[string][]byte{ + ".agents/skills/respond/SKILL.md": files[".agents/skills/respond/SKILL.md"], + ".claude/skills/respond/SKILL.md": files[".claude/skills/respond/SKILL.md"], + } + artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: files["flow.ts"], + DependencyLockPath: "package-lock.json", DependencyLock: files["package-lock.json"], + Projections: []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}, GeneratedProjections: generated, + }) + if err != nil { + t.Fatal(err) + } + generate := func(_ controlprogram.Compiled, projections []hostprojection.ID) (map[string][]byte, error) { + if want := []hostprojection.ID{hostprojection.Claude, hostprojection.Codex}; !reflect.DeepEqual(projections, want) { + t.Fatalf("generator projections = %v, want canonical %v", projections, want) + } + return generated, nil + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}, generate); err != nil { + t.Fatal(err) + } +} + +func TestArtifactRejectsGeneratedPathsOutsideHostProjectionRoots(t *testing.T) { compiled, err := controlprogram.Compile(incidentProgram(), nil) if err != nil { t.Fatal(err) @@ -746,7 +833,7 @@ func TestArtifactRejectsGeneratedPathsOutsideHostSkillRoots(t *testing.T) { _, _, err = controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("source"), DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), - GeneratedSkills: map[string][]byte{"README.md": []byte("delete me")}, + Projections: []hostprojection.ID{hostprojection.Codex}, GeneratedProjections: map[string][]byte{"README.md": []byte("delete me")}, }) if err == nil { t.Fatal("artifact accepted an arbitrary generated deletion path") @@ -773,18 +860,18 @@ func TestArtifactBindsExactForegroundWorkAssets(t *testing.T) { } artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: files["flow.ts"], - DependencyLockPath: "package-lock.json", DependencyLock: files["package-lock.json"], GeneratedSkills: map[string][]byte{}, + DependencyLockPath: "package-lock.json", DependencyLock: files["package-lock.json"], Projections: []hostprojection.ID{}, GeneratedProjections: map[string][]byte{}, }) if err != nil { t.Fatal(err) } - if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err != nil { + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, []hostprojection.ID{}, nil); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repository, "instructions.md"), []byte("different instructions"), 0o600); err != nil { t.Fatal(err) } - if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err == nil || !strings.Contains(err.Error(), "work asset") { + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, []hostprojection.ID{}, nil); err == nil || !strings.Contains(err.Error(), "work asset") { t.Fatalf("changed work asset result = %v", err) } } diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 5989d1b..8dd036a 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -16,6 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/core" "github.com/operatorstack/boatstack/boatstack/delivery" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) func TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint(t *testing.T) { @@ -217,7 +218,7 @@ func TestDomainNeutralInvocationFixtureCompilesAndMissingProducerFails(t *testin if err != nil { t.Fatal(err) } - if _, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}); err != nil { + if _, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}); err != nil { t.Fatalf("generate domain-neutral entry drivers: %v", err) } missingRaw, err := compile("incident-response-invocation-missing.flow.ts") diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go index 5fada7f..5b1ea19 100644 --- a/boatstack/distribution/standard_test.go +++ b/boatstack/distribution/standard_test.go @@ -64,7 +64,7 @@ func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, - Hosts: []string{"cli"}, Extensions: []protocol.SubprocessExtensionSettings{x}, + Hosts: []string{"cli"}, Projections: []string{}, Extensions: []protocol.SubprocessExtensionSettings{x}, } candidateBytes, err := json.Marshal(candidateConfig) if err != nil { @@ -194,7 +194,7 @@ func repositoryFixture(t *testing.T, extensions []protocol.SubprocessExtensionSe Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, - Hosts: []string{"cli"}, Extensions: extensions, + Hosts: []string{"cli"}, Projections: []string{}, Extensions: extensions, } raw, err := json.Marshal(configuration) if err != nil { diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/projections.go similarity index 92% rename from boatstack/flow/softwaredelivery/skills.go rename to boatstack/flow/softwaredelivery/projections.go index 9de6b5d..3e1a998 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/projections.go @@ -3,35 +3,48 @@ package softwaredelivery import ( "encoding/hex" "fmt" - "path/filepath" "strings" "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/flow/skillprojection" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) -func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { +func GenerateProjections(compiled controlprogram.Compiled, projections []hostprojection.ID) (map[string][]byte, error) { + canonical, err := hostprojection.ParseIDs(hostprojection.Strings(projections)) + if err != nil { + return nil, err + } result := map[string][]byte{} const exactCheckoutAttributes = "** -text" + for _, projection := range canonical { + if path, content, ok := hostprojection.SharedCheckoutPath(projection); ok { + result[path] = content + } + } for _, entry := range compiled.Document.Entries { slug := flowSkillSlug(compiled.Document.Program.ID, entry.ID) if slug == "boatstack-update" { - return nil, fmt.Errorf("generated Flow skill %q is reserved for kernel maintenance", slug) + return nil, fmt.Errorf("generated Flow projection %q is reserved for kernel maintenance", slug) } - for _, host := range hosts { - skill := renderSkill(compiled, entry, slug, host) - switch host { - case "codex": - root := filepath.ToSlash(filepath.Join(".agents", "skills", slug)) - result[root+"/.gitattributes"] = []byte(exactCheckoutAttributes) - result[root+"/SKILL.md"] = skill - result[root+"/agents/openai.yaml"] = []byte(fmt.Sprintf("interface:\n display_name: %q\n short_description: %q\n default_prompt: %q\npolicy:\n allow_implicit_invocation: false\n", title(slug), entry.Description, "Use $"+slug+" to run the repository-owned Boatstack Flow entry.")) - case "claude": - root := filepath.ToSlash(filepath.Join(".claude", "skills", slug)) - result[root+"/.gitattributes"] = []byte(exactCheckoutAttributes) - result[root+"/SKILL.md"] = skill + for _, projection := range canonical { + paths, err := hostprojection.FlowPaths(projection, slug) + if err != nil { + return nil, err + } + content := renderProjection(compiled, entry, slug, string(projection)) + switch projection { + case hostprojection.Codex: + result[paths[0]] = []byte(exactCheckoutAttributes) + result[paths[1]] = content + result[paths[2]] = []byte(fmt.Sprintf("interface:\n display_name: %q\n short_description: %q\n default_prompt: %q\npolicy:\n allow_implicit_invocation: false\n", title(slug), entry.Description, "Use $"+slug+" to run the repository-owned Boatstack Flow entry.")) + case hostprojection.Claude: + result[paths[0]] = []byte(exactCheckoutAttributes) + result[paths[1]] = content + case hostprojection.Cursor, hostprojection.Gemini: + result[paths[0]] = content default: - return nil, fmt.Errorf("unsupported generated Flow skill host %q", host) + return nil, fmt.Errorf("unsupported generated Flow projection %q", projection) } } } @@ -47,7 +60,7 @@ func flowSkillSlug(programID, entryID string) string { return programID + "-" + encodedEntry } -func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, slug, host string) []byte { +func renderProjection(compiled controlprogram.Compiled, entry controlprogram.Entry, slug, host string) []byte { description := entry.Description if description == "" { description = "Run repository Flow entry " + entry.ID + " to target " + entry.Target + "." diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/projections_test.go similarity index 68% rename from boatstack/flow/softwaredelivery/skills_test.go rename to boatstack/flow/softwaredelivery/projections_test.go index a1d0b05..1fbf014 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/projections_test.go @@ -2,14 +2,19 @@ package softwaredelivery_test import ( "encoding/json" + "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" ) -func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { +func TestGeneratedProjectionsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { // control-law: hosts-receive-the-same-entry-contract-without-kernel-inference truth := true compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ @@ -20,7 +25,7 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { Delegation: &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"}, }}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -75,6 +80,125 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } } +func TestGeneratedProjectionsCoverEverySelectedTargetAndNone(t *testing.T) { + // control-law: generated-files-depend-only-on-explicit-projections + truth := true + compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Targets: []controlprogram.Target{{ID: "done", Predicate: controlprogram.Predicate{True: &truth}}}, + Entries: []controlprogram.Entry{{ID: "run", Target: "done", Description: "Run delivery"}}, + }} + files, err := softwareflow.GenerateProjections(compiled, hostprojection.CanonicalIDs()) + if err != nil { + t.Fatal(err) + } + want := []string{ + ".agents/skills/product-delivery-run/.gitattributes", + ".agents/skills/product-delivery-run/SKILL.md", + ".agents/skills/product-delivery-run/agents/openai.yaml", + ".claude/skills/product-delivery-run/.gitattributes", + ".claude/skills/product-delivery-run/SKILL.md", + ".cursor/commands/.gitattributes", + ".cursor/commands/product-delivery-run.md", + ".gemini/skills/.gitattributes", + ".gemini/skills/product-delivery-run/SKILL.md", + } + if len(files) != len(want) { + t.Fatalf("generated projections = %v", files) + } + for _, path := range want { + if len(files[path]) == 0 { + t.Fatalf("missing generated projection %s", path) + } + } + for host, path := range map[string]string{ + "codex": ".agents/skills/product-delivery-run/SKILL.md", + "claude": ".claude/skills/product-delivery-run/SKILL.md", + "cursor": ".cursor/commands/product-delivery-run.md", + "gemini": ".gemini/skills/product-delivery-run/SKILL.md", + } { + if !strings.Contains(string(files[path]), "--host "+host) { + t.Fatalf("%s projection does not bind its runtime host", host) + } + } + empty, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{}) + if err != nil || len(empty) != 0 { + t.Fatalf("empty projections = %v, %v", empty, err) + } + for id, count := range map[hostprojection.ID]int{ + hostprojection.Codex: 3, hostprojection.Claude: 2, hostprojection.Cursor: 2, hostprojection.Gemini: 2, + } { + selected, selectedErr := softwareflow.GenerateProjections(compiled, []hostprojection.ID{id}) + if selectedErr != nil || len(selected) != count { + t.Fatalf("%s-only projections = %v, %v", id, selected, selectedErr) + } + for path := range selected { + if !hostprojection.ValidFlowPath(path) { + t.Fatalf("%s-only selection generated invalid path %s", id, path) + } + } + } +} + +func TestEveryGeneratedPathSurvivesAutoCRLFCheckoutExactly(t *testing.T) { + // control-law: checkout-never-rewrites-hash-bound-projection-bytes + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is unavailable") + } + truth := true + compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "checkout-proof"}, + Targets: []controlprogram.Target{{ID: "done", Predicate: controlprogram.Predicate{True: &truth}}}, + Entries: []controlprogram.Entry{{ID: "run", Target: "done", Description: "Line one\nLine two"}}, + }} + files, err := softwareflow.GenerateProjections(compiled, hostprojection.CanonicalIDs()) + if err != nil { + t.Fatal(err) + } + maintenance, _, err := effects.ProjectedHostProjectionFiles(hostprojection.CanonicalIDs()) + if err != nil { + t.Fatal(err) + } + for path, raw := range maintenance { + files[path] = raw + } + repository := t.TempDir() + runGit := func(arguments ...string) { + t.Helper() + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("git %v: %v\n%s", arguments, runErr, output) + } + } + runGit("init", "-q") + runGit("config", "user.email", "fixture@example.invalid") + runGit("config", "user.name", "Fixture") + runGit("config", "core.autocrlf", "true") + for path, raw := range files { + absolute := filepath.Join(repository, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absolute, raw, 0o644); err != nil { + t.Fatal(err) + } + } + runGit("add", ".") + runGit("commit", "-q", "-m", "projection bytes") + for path := range files { + if err := os.Remove(filepath.Join(repository, filepath.FromSlash(path))); err != nil { + t.Fatal(err) + } + } + runGit("checkout", "--", ".") + for path, expected := range files { + actual, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(path))) + if err != nil || string(actual) != string(expected) { + t.Fatalf("checkout rewrote %s: %v", path, err) + } + } +} + func TestGeneratedSoftwareDeliverySkillMakesProgramDriftCoreachableWithoutImplicitAcceptance(t *testing.T) { // control-law: generated-driver-program-drift-has-an-exact-human-authorized-resumption truth := true @@ -86,7 +210,7 @@ func TestGeneratedSoftwareDeliverySkillMakesProgramDriftCoreachableWithoutImplic Targets: []controlprogram.Target{{ID: "mitigated", Predicate: controlprogram.Predicate{True: &truth}}}, Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -114,7 +238,7 @@ func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) { Program: controlprogram.Program{ID: "product-delivery"}, Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: description}}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -149,7 +273,7 @@ func TestGeneratedSkillExplanationIsEntryOptInWithHostParity(t *testing.T) { {ID: "quiet", Target: "published-pr"}, }, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -176,14 +300,14 @@ func TestGeneratedSkillExplanationIsEntryOptInWithHostParity(t *testing.T) { } } -func TestGeneratedSkillsProjectForegroundWorkProtocolWithHostParity(t *testing.T) { +func TestGeneratedProjectionsProjectForegroundWorkProtocolWithHostParity(t *testing.T) { // control-law: every supported agent host projects the same foreground-work boundary compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "incident-response"}, Work: []controlprogram.WorkContract{{ID: "diagnose"}}, Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -199,7 +323,7 @@ func TestGeneratedSkillsProjectForegroundWorkProtocolWithHostParity(t *testing.T } } -func TestGeneratedSkillsProjectGateEvidenceWorkSuspensionWithHostParity(t *testing.T) { +func TestGeneratedProjectionsProjectGateEvidenceWorkSuspensionWithHostParity(t *testing.T) { // control-law: missing deterministic gate evidence suspends bounded work // without becoming human input, fabricated evidence, or a terminal stop. compiled := controlprogram.Compiled{Document: controlprogram.Document{ @@ -219,7 +343,7 @@ func TestGeneratedSkillsProjectGateEvidenceWorkSuspensionWithHostParity(t *testi }}, Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -248,7 +372,7 @@ func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testin {ID: "cancel", Target: "safely-abandoned"}, }, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) if err != nil { t.Fatal(err) } @@ -266,12 +390,12 @@ func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testin } } -func TestGeneratedSkillsRejectKernelMaintenanceIdentity(t *testing.T) { +func TestGeneratedProjectionsRejectKernelMaintenanceIdentity(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "boatstack"}, Entries: []controlprogram.Entry{{ID: "update", Target: "done"}}, }} - if _, err := softwareflow.GenerateSkills(compiled, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "reserved") { + if _, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex}); err == nil || !strings.Contains(err.Error(), "reserved") { t.Fatalf("maintenance collision result = %v", err) } } @@ -283,7 +407,7 @@ func TestGeneratedSkillIdentityIsInjectiveAcrossProgramEntryPairs(t *testing.T) Program: controlprogram.Program{ID: program}, Entries: []controlprogram.Entry{{ID: entry, Target: "done"}}, }} - files, err := softwareflow.GenerateSkills(compiled, []string{"codex"}) + files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex}) if err != nil { t.Fatal(err) } diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index e050a86..41a3285 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -455,6 +455,7 @@ func classifiedProductionFile(relative string) bool { strings.HasPrefix(relative, "delivery/") || strings.HasPrefix(relative, "core/") || strings.HasPrefix(relative, "flow/") || strings.HasPrefix(relative, "distribution/") || strings.HasPrefix(relative, "extension/") || strings.HasPrefix(relative, "internal/softwaredelivery/") || + strings.HasPrefix(relative, "internal/hostprojection/") || strings.HasPrefix(relative, "internal/buildinfo/") || strings.HasPrefix(relative, "internal/runtime/") || strings.HasPrefix(relative, "internal/retromine/") || strings.HasPrefix(relative, "internal/testprogram/") || strings.HasPrefix(relative, "kernel/") || strings.HasPrefix(relative, "sdk/") || strings.HasPrefix(relative, "analysis/") diff --git a/boatstack/internal/hostprojection/projection.go b/boatstack/internal/hostprojection/projection.go new file mode 100644 index 0000000..47f7a5a --- /dev/null +++ b/boatstack/internal/hostprojection/projection.go @@ -0,0 +1,232 @@ +package hostprojection + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// ID identifies a repository host projection. Projection selection controls +// generated files only; it is not runtime-host authority. +type ID string + +const ( + Codex ID = "codex" + Claude ID = "claude" + Cursor ID = "cursor" + Gemini ID = "gemini" +) + +const SelectionSchemaVersion = 1 + +var canonical = []ID{Claude, Codex, Cursor, Gemini} +var generatedSlug = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) +var lowercaseSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func CanonicalIDs() []ID { return append([]ID(nil), canonical...) } + +func CanonicalStrings() []string { + result := make([]string, len(canonical)) + for index, id := range canonical { + result[index] = string(id) + } + return result +} + +func ValidSHA256(value string) bool { return lowercaseSHA256.MatchString(value) } + +// Parse validates an explicit projection selection against the enabled +// runtime hosts and returns it in canonical order. A nil selection represents +// a missing or null required JSON field; an explicit empty slice is valid. +func Parse(values, hosts []string) ([]ID, error) { + if values == nil { + return nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: Boatstack project configuration requires explicit projections") + } + result, err := ParseIDs(values) + if err != nil { + return nil, err + } + enabled := make(map[string]bool, len(hosts)) + for _, host := range hosts { + enabled[host] = true + } + for _, id := range result { + if !enabled[string(id)] { + return nil, fmt.Errorf("PROJECT_PROJECTION_HOST_DISABLED: projection %q requires the matching runtime host", id) + } + } + return result, nil +} + +// ParseIDs validates an explicit projection set without assigning runtime-host +// authority. It is used by artifacts that bind selection but do not own host +// admission policy. +func ParseIDs(values []string) ([]ID, error) { + if values == nil { + return nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: Boatstack project configuration requires explicit projections") + } + allowed := make(map[ID]bool, len(canonical)) + for _, id := range canonical { + allowed[id] = true + } + seen := make(map[ID]bool, len(values)) + result := make([]ID, 0, len(values)) + for _, value := range values { + id := ID(value) + if !allowed[id] || seen[id] { + return nil, fmt.Errorf("PROJECT_PROJECTION_INVALID: unsupported or duplicated projection %q", value) + } + seen[id] = true + result = append(result, id) + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result, nil +} + +func Strings(values []ID) []string { + result := make([]string, len(values)) + for index, value := range values { + result[index] = string(value) + } + return result +} + +func SelectionFingerprint(values []ID) (string, error) { + canonicalValues, err := ParseIDs(Strings(values)) + if err != nil { + return "", err + } + payload := struct { + SchemaVersion int `json:"schema_version"` + Projections []string `json:"projections"` + }{SchemaVersion: SelectionSchemaVersion, Projections: Strings(canonicalValues)} + if payload.Projections == nil { + payload.Projections = []string{} + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} + +func FlowPaths(id ID, slug string) ([]string, error) { + if !validSlug(slug) || slug == "boatstack-update" { + return nil, fmt.Errorf("FLOW_PROJECTION_PATH_INVALID: invalid or reserved Flow projection slug %q", slug) + } + switch id { + case Codex: + root := filepath.ToSlash(filepath.Join(".agents", "skills", slug)) + return []string{root + "/.gitattributes", root + "/SKILL.md", root + "/agents/openai.yaml"}, nil + case Claude: + root := filepath.ToSlash(filepath.Join(".claude", "skills", slug)) + return []string{root + "/.gitattributes", root + "/SKILL.md"}, nil + case Cursor: + return []string{filepath.ToSlash(filepath.Join(".cursor", "commands", slug+".md"))}, nil + case Gemini: + return []string{filepath.ToSlash(filepath.Join(".gemini", "skills", slug, "SKILL.md"))}, nil + default: + return nil, fmt.Errorf("FLOW_PROJECTION_PATH_INVALID: unsupported projection %q", id) + } +} + +// SharedCheckoutPath returns checkout metadata shared by every Flow projected +// to a host. These paths are reference-counted separately from slug-scoped +// outputs because several Flow ownership records may bind the same file. +func SharedCheckoutPath(id ID) (string, []byte, bool) { + switch id { + case Cursor: + return ".cursor/commands/.gitattributes", []byte(".gitattributes -text\n*.md -text\n"), true + case Gemini: + return ".gemini/skills/.gitattributes", []byte(".gitattributes -text\n** -text\n"), true + default: + return "", nil, false + } +} + +func IsSharedCheckoutPath(path string) bool { + for _, id := range canonical { + candidate, _, ok := SharedCheckoutPath(id) + if ok && path == candidate { + return true + } + } + return false +} + +// MaintenancePaths returns the complete canonical repository projection for +// the Kernel-owned update driver. Shared host-level attributes are part of the +// maintenance manifest so they have one serialized owner across all Flows. +func MaintenancePaths(id ID) ([]string, error) { + switch id { + case Codex: + root := ".agents/skills/boatstack-update" + return []string{root + "/.gitattributes", root + "/SKILL.md", root + "/agents/openai.yaml"}, nil + case Claude: + root := ".claude/skills/boatstack-update" + return []string{root + "/.gitattributes", root + "/SKILL.md"}, nil + case Cursor: + return []string{".cursor/commands/.gitattributes", ".cursor/commands/boatstack-update.md"}, nil + case Gemini: + return []string{".gemini/skills/.gitattributes", ".gemini/skills/boatstack-update/SKILL.md"}, nil + default: + return nil, fmt.Errorf("HOST_PROJECTION_PATH_INVALID: unsupported projection %q", id) + } +} + +func ValidMaintenancePath(path string) bool { + if filepath.ToSlash(filepath.Clean(path)) != path { + return false + } + for _, id := range canonical { + paths, _ := MaintenancePaths(id) + for _, candidate := range paths { + if path == candidate { + return true + } + } + } + return false +} + +func ValidFlowPath(value string) bool { + if IsSharedCheckoutPath(value) { + return true + } + if !safeRelative(value) { + return false + } + parts := strings.Split(value, "/") + if len(parts) == 4 && (parts[0] == ".agents" || parts[0] == ".claude") && parts[1] == "skills" && validSlug(parts[2]) && parts[2] != "boatstack-update" && parts[3] == ".gitattributes" { + return true + } + if len(parts) == 4 && parts[0] == ".agents" && parts[1] == "skills" && validSlug(parts[2]) && parts[2] != "boatstack-update" && parts[3] == "SKILL.md" { + return true + } + if len(parts) == 5 && parts[0] == ".agents" && parts[1] == "skills" && validSlug(parts[2]) && parts[2] != "boatstack-update" && parts[3] == "agents" && parts[4] == "openai.yaml" { + return true + } + if len(parts) == 4 && parts[0] == ".claude" && parts[1] == "skills" && validSlug(parts[2]) && parts[2] != "boatstack-update" && parts[3] == "SKILL.md" { + return true + } + if len(parts) == 3 && parts[0] == ".cursor" && parts[1] == "commands" && strings.HasSuffix(parts[2], ".md") { + return validSlug(strings.TrimSuffix(parts[2], ".md")) && strings.TrimSuffix(parts[2], ".md") != "boatstack-update" + } + return len(parts) == 4 && parts[0] == ".gemini" && parts[1] == "skills" && validSlug(parts[2]) && parts[2] != "boatstack-update" && parts[3] == "SKILL.md" +} + +func validSlug(value string) bool { return generatedSlug.MatchString(value) } + +func safeRelative(value string) bool { + if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) { + return false + } + clean := filepath.Clean(filepath.FromSlash(value)) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value +} diff --git a/boatstack/internal/hostprojection/projection_test.go b/boatstack/internal/hostprojection/projection_test.go new file mode 100644 index 0000000..a3e03a1 --- /dev/null +++ b/boatstack/internal/hostprojection/projection_test.go @@ -0,0 +1,104 @@ +package hostprojection + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseRequiresExplicitSubsetAndCanonicalizes(t *testing.T) { + if _, err := Parse(nil, []string{"cli"}); err == nil || !strings.Contains(err.Error(), "PROJECT_PROJECTIONS_REQUIRED") { + t.Fatalf("missing projections error = %v", err) + } + got, err := Parse([]string{"gemini", "codex"}, []string{"cli", "codex", "gemini"}) + if err != nil { + t.Fatal(err) + } + if want := []ID{Codex, Gemini}; !reflect.DeepEqual(got, want) { + t.Fatalf("projections = %v, want %v", got, want) + } + for _, test := range []struct { + values []string + hosts []string + code string + }{ + {[]string{"cli"}, []string{"cli"}, "PROJECT_PROJECTION_INVALID"}, + {[]string{"codex", "codex"}, []string{"cli", "codex"}, "PROJECT_PROJECTION_INVALID"}, + {[]string{"cursor"}, []string{"cli"}, "PROJECT_PROJECTION_HOST_DISABLED"}, + } { + if _, err := Parse(test.values, test.hosts); err == nil || !strings.Contains(err.Error(), test.code) { + t.Fatalf("Parse(%v, %v) error = %v, want %s", test.values, test.hosts, err, test.code) + } + } + if got, err := Parse([]string{}, []string{"cli"}); err != nil || len(got) != 0 { + t.Fatalf("explicit empty selection = %v, %v", got, err) + } +} + +func TestSelectionFingerprintCanonicalizesOrderAndMembership(t *testing.T) { + one, err := SelectionFingerprint([]ID{Cursor, Codex}) + if err != nil { + t.Fatal(err) + } + two, _ := SelectionFingerprint([]ID{Codex, Cursor}) + three, _ := SelectionFingerprint([]ID{Codex}) + if one != two || one == three || one != "b86bf1f15f6aace0a6153be663f57dd604c2d6f92df3c6921b593cc6fb4d4370" { + t.Fatalf("fingerprints one=%s two=%s three=%s", one, two, three) + } + if !ValidSHA256(one) || ValidSHA256(strings.ToUpper(one)) || ValidSHA256(strings.Repeat("g", 64)) { + t.Fatal("lowercase SHA-256 validation accepted a non-canonical digest") + } +} + +func TestFlowPathsAreInjectiveAndStrict(t *testing.T) { + seen := map[string]bool{} + for _, id := range CanonicalIDs() { + for _, slug := range []string{"product-delivery-run", "x0-product-x0-run"} { + paths, err := FlowPaths(id, slug) + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + if seen[path] || !ValidFlowPath(path) { + t.Fatalf("non-injective or invalid path %q", path) + } + seen[path] = true + } + } + } + for _, id := range []ID{Cursor, Gemini} { + path, content, ok := SharedCheckoutPath(id) + if !ok || len(content) == 0 || !ValidFlowPath(path) || !IsSharedCheckoutPath(path) { + t.Fatalf("%s shared checkout path = %q, %q, %v", id, path, content, ok) + } + } + for _, path := range []string{"/tmp/SKILL.md", "../SKILL.md", `.cursor\\commands\\run.md`, ".agents/skills/boatstack-update/SKILL.md", ".gemini/skills//SKILL.md"} { + if ValidFlowPath(path) { + t.Fatalf("unsafe path accepted: %q", path) + } + } +} + +func TestMaintenancePathsAreCanonicalAndInjective(t *testing.T) { + seen := map[string]ID{} + for _, id := range CanonicalIDs() { + paths, err := MaintenancePaths(id) + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + if owner, exists := seen[path]; exists { + t.Fatalf("%s is shared by %s and %s", path, owner, id) + } + seen[path] = id + if !ValidMaintenancePath(path) { + t.Fatalf("canonical maintenance path rejected: %s", path) + } + } + } + for _, path := range []string{"legacy.json", ".cursor/commands/../escape.md", ".agents/skills/other/SKILL.md"} { + if ValidMaintenancePath(path) { + t.Fatalf("unsafe or unrelated path accepted: %s", path) + } + } +} diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go index 43ee7ba..aa9ab4e 100644 --- a/boatstack/internal/runtime/control_bundle.go +++ b/boatstack/internal/runtime/control_bundle.go @@ -1,6 +1,7 @@ package runtime import ( + "bufio" "bytes" "context" "crypto/sha256" @@ -12,6 +13,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" ) @@ -98,14 +100,25 @@ func NewControlBundleSnapshotWithMemberSets(files map[string][]byte, absent []st // ReplaceControlBundleFile derives a target snapshot without trusting a // caller-supplied target fingerprint. func ReplaceControlBundleFile(snapshot ControlBundleSnapshot, path string, raw []byte) (ControlBundleSnapshot, error) { + digest := sha256.Sum256(raw) + return replaceControlBundleBinding(snapshot, ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])}) +} + +// ReplaceControlBundleFileAbsent binds an exact removal in a target snapshot. +// It is used when a manifest-owned projection is retired so stale bytes cannot +// silently remain outside the admitted target bundle. +func ReplaceControlBundleFileAbsent(snapshot ControlBundleSnapshot, path string) (ControlBundleSnapshot, error) { + return replaceControlBundleBinding(snapshot, ControlBundleFile{Path: path, Absent: true}) +} + +func replaceControlBundleBinding(snapshot ControlBundleSnapshot, binding ControlBundleFile) (ControlBundleSnapshot, error) { if err := snapshot.validate(); err != nil { return ControlBundleSnapshot{}, err } + path := binding.Path if !safeProjectionRelative(path) { return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) } - digest := sha256.Sum256(raw) - binding := ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])} files := append([]ControlBundleFile(nil), snapshot.Files...) replaced := false for index := range files { @@ -500,20 +513,43 @@ func VerifyControlBundleRevision(ctx context.Context, repository, revision strin return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s member set %s/*%s does not match the admitted bundle", revision, memberSet.Root, memberSet.Suffix) } } + var requests bytes.Buffer + for _, file := range snapshot.Files { + fmt.Fprintf(&requests, "%s:%s\n", revision, file.Path) + } + command := exec.CommandContext(ctx, "git", "cat-file", "--batch") + command.Dir, command.Stdin = repository, &requests + output, err := command.Output() + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: inspect revision %s: %w", revision, err) + } + reader := bufio.NewReader(bytes.NewReader(output)) for _, file := range snapshot.Files { + header, readErr := reader.ReadString('\n') + if readErr != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks batch evidence for %s", revision, file.Path) + } + fields := strings.Fields(header) + missing := len(fields) == 2 && fields[1] == "missing" if file.Absent { - command := exec.CommandContext(ctx, "git", "cat-file", "-e", revision+":"+file.Path) - command.Dir = repository - if command.Run() == nil { + if !missing { return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s unexpectedly contains %s", revision, file.Path) } continue } - command := exec.CommandContext(ctx, "git", "show", revision+":"+file.Path) - command.Dir = repository - raw, err := command.Output() - if err != nil { - return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks %s", revision, file.Path) + if missing || len(fields) != 3 || fields[1] != "blob" { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks regular file %s", revision, file.Path) + } + size, parseErr := strconv.ParseInt(fields[2], 10, 64) + if parseErr != nil || size < 0 || size > 64<<20 { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has invalid size for %s", revision, file.Path) + } + raw := make([]byte, size) + if _, readErr := io.ReadFull(reader, raw); readErr != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has incomplete %s", revision, file.Path) + } + if separator, readErr := reader.ReadByte(); readErr != nil || separator != '\n' { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has malformed batch evidence for %s", revision, file.Path) } digest := sha256.Sum256(raw) if hex.EncodeToString(digest[:]) != file.SHA256 { diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go index f5e5a4b..df11a51 100644 --- a/boatstack/internal/runtime/control_bundle_test.go +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "fmt" "os" "os/exec" "path/filepath" @@ -70,6 +71,40 @@ func TestControlBundleCanonicalizesFilesAndBindsAbsence(t *testing.T) { } } +func TestControlBundleRejectsPathsThatCanSplitGitBatchRequests(t *testing.T) { + for _, path := range []string{ + ".boatstack/project.json\nHEAD:.boatstack/runtime.json", + ".boatstack/project.json\rHEAD:.boatstack/runtime.json", + ".boatstack/project.json\x00suffix", + } { + t.Run(fmt.Sprintf("%q", path), func(t *testing.T) { + if _, err := NewControlBundleSnapshot(map[string][]byte{path: []byte("candidate")}); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Fatalf("control path %q was not rejected: %v", path, err) + } + }) + } +} + +func TestReplaceControlBundleFileAbsentBindsRetirement(t *testing.T) { + snapshot, err := NewControlBundleSnapshot(map[string][]byte{ + ".boatstack/project.json": []byte("project"), + ".cursor/commands/boatstack-update.md": []byte("projection"), + }) + if err != nil { + t.Fatal(err) + } + retired, err := ReplaceControlBundleFileAbsent(snapshot, ".cursor/commands/boatstack-update.md") + if err != nil { + t.Fatal(err) + } + for _, file := range retired.Files { + if file.Path == ".cursor/commands/boatstack-update.md" && file.Absent && file.SHA256 == "" { + return + } + } + t.Fatalf("retirement is not bound: %#v", retired.Files) +} + func TestControlBundleVerifiesRootRevisionAndExactHead(t *testing.T) { repository := t.TempDir() runBundleGit(t, repository, "init", "-q") diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 77f6375..7f91169 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -12,6 +12,8 @@ import ( "runtime" "sort" "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) func RunFlowFrontend(ctx context.Context, executable, sourceName string, source []byte) ([]byte, error) { @@ -139,6 +141,27 @@ func applyFlowProjectionWithOwnership(repository string, writes []ProjectionWrit writes = append([]ProjectionWrite(nil), writes...) removals = append([]ProjectionRemoval(nil), removals...) expectations = append([]ProjectionExpectation(nil), expectations...) + if ownership != nil { + filtered := removals[:0] + for _, removal := range removals { + relative, relativeErr := filepath.Rel(repository, removal.Path) + if relativeErr != nil { + return relativeErr + } + relative = filepath.ToSlash(relative) + if hostprojection.IsSharedCheckoutPath(relative) { + referenced, referenceErr := sharedProjectionReferenced(repository, relative, removal.ExpectedSHA256, ownership.next.SourcePath, true) + if referenceErr != nil { + return referenceErr + } + if referenced { + continue + } + } + filtered = append(filtered, removal) + } + removals = filtered + } sort.Slice(writes, func(i, j int) bool { if writes[i].PublishLast != writes[j].PublishLast { return !writes[i].PublishLast diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 0912c93..898da71 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -9,8 +9,112 @@ import ( "runtime" "strings" "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) +func TestSharedCheckoutMetadataRetiresOnlyAfterFinalFlowOwner(t *testing.T) { + // control-law: shared-checkout-metadata-survives-until-its-final-exact-owner-retires + repository := resolvedTemporaryRepository(t) + sharedRelative, sharedContent, ok := hostprojection.SharedCheckoutPath(hostprojection.Cursor) + if !ok { + t.Fatal("Cursor shared checkout path is unavailable") + } + sharedPath := filepath.Join(repository, filepath.FromSlash(sharedRelative)) + sharedDigest := projectionDigest(sharedContent) + type flowFixture struct { + source string + artifact string + raw []byte + } + flows := []flowFixture{ + {source: ".boatstack/flows/one.flow.ts", artifact: ".boatstack/flows/one.flow.ir.json", raw: []byte("artifact one")}, + {source: ".boatstack/flows/two.flow.ts", artifact: ".boatstack/flows/two.flow.ir.json", raw: []byte("artifact two")}, + } + for _, flow := range flows { + prior, err := LoadFlowProjectionOwnership(repository, flow.source) + if err != nil { + t.Fatal(err) + } + next := NewFlowProjectionOwnership(flow.source, flow.artifact, flow.raw, strings.Repeat("a", 64), map[string][]byte{sharedRelative: sharedContent}) + writes := []ProjectionWrite{ + {Path: sharedPath, Content: sharedContent, Mode: 0o644}, + {Path: filepath.Join(repository, filepath.FromSlash(flow.artifact)), Content: flow.raw, Mode: 0o644, PublishLast: true}, + } + if err := ApplyOwnedFlowProjection(repository, writes, nil, nil, prior, next); err != nil { + t.Fatal(err) + } + } + for index, flow := range flows { + prior, err := LoadFlowProjectionOwnership(repository, flow.source) + if err != nil { + t.Fatal(err) + } + nextRaw := append([]byte(nil), flow.raw...) + nextRaw = append(nextRaw, []byte(" retired")...) + next := NewFlowProjectionOwnership(flow.source, flow.artifact, nextRaw, strings.Repeat("b", 64), map[string][]byte{}) + writes := []ProjectionWrite{{ + Path: filepath.Join(repository, filepath.FromSlash(flow.artifact)), Content: nextRaw, Mode: 0o644, + ExpectedPreviousSHA256: projectionDigest(flow.raw), PublishLast: true, + }} + removals := []ProjectionRemoval{{Path: sharedPath, ExpectedSHA256: sharedDigest, AllowMissing: true}} + if err := ApplyOwnedFlowProjection(repository, writes, removals, nil, prior, next); err != nil { + t.Fatal(err) + } + _, statErr := os.Stat(sharedPath) + if index == 0 && statErr != nil { + t.Fatalf("first owner retired shared metadata: %v", statErr) + } + if index == 1 && !os.IsNotExist(statErr) { + t.Fatalf("final owner did not retire shared metadata: %v", statErr) + } + } +} + +func TestMaintenanceOwnershipKeepsSharedCheckoutMetadata(t *testing.T) { + // control-law: Flow-retirement-cannot-remove-maintenance-owned-checkout-metadata + repository := resolvedTemporaryRepository(t) + sharedRelative, sharedContent, _ := hostprojection.SharedCheckoutPath(hostprojection.Gemini) + sharedPath := filepath.Join(repository, filepath.FromSlash(sharedRelative)) + manifestPath := filepath.Join(repository, ".boatstack", "host-projections.json") + if err := os.MkdirAll(filepath.Dir(sharedPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sharedPath, sharedContent, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"schema_version":2,"files":{"` + sharedRelative + `":"` + projectionDigest(sharedContent) + `"}}` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + flow := ".boatstack/flows/one.flow.ts" + artifact := ".boatstack/flows/one.flow.ir.json" + prior, err := LoadFlowProjectionOwnership(repository, flow) + if err != nil { + t.Fatal(err) + } + oldRaw := []byte("old artifact") + old := NewFlowProjectionOwnership(flow, artifact, oldRaw, strings.Repeat("a", 64), map[string][]byte{sharedRelative: sharedContent}) + if err := ApplyOwnedFlowProjection(repository, []ProjectionWrite{{Path: sharedPath, Content: sharedContent, Mode: 0o644}, {Path: filepath.Join(repository, filepath.FromSlash(artifact)), Content: oldRaw, Mode: 0o644, PublishLast: true}}, nil, nil, prior, old); err != nil { + t.Fatal(err) + } + prior, err = LoadFlowProjectionOwnership(repository, flow) + if err != nil { + t.Fatal(err) + } + newRaw := []byte("new artifact") + next := NewFlowProjectionOwnership(flow, artifact, newRaw, strings.Repeat("b", 64), map[string][]byte{}) + if err := ApplyOwnedFlowProjection(repository, []ProjectionWrite{{Path: filepath.Join(repository, filepath.FromSlash(artifact)), Content: newRaw, Mode: 0o644, ExpectedPreviousSHA256: projectionDigest(oldRaw), PublishLast: true}}, []ProjectionRemoval{{Path: sharedPath, ExpectedSHA256: projectionDigest(sharedContent), AllowMissing: true}}, nil, prior, next); err != nil { + t.Fatal(err) + } + if actual, err := os.ReadFile(sharedPath); err != nil || string(actual) != string(sharedContent) { + t.Fatalf("maintenance-owned metadata = %q, %v", actual, err) + } +} + func TestVerifyFlowProjectionAtRevisionBindsActiveBytesToWorkspaceBase(t *testing.T) { // control-law: workspace-base-contains-the-active-flow-projection repository, err := filepath.EvalSymlinks(t.TempDir()) diff --git a/boatstack/internal/runtime/flow_ownership.go b/boatstack/internal/runtime/flow_ownership.go index c95497f..2f9e53c 100644 --- a/boatstack/internal/runtime/flow_ownership.go +++ b/boatstack/internal/runtime/flow_ownership.go @@ -10,20 +10,24 @@ import ( "io" "os" "path/filepath" + "sort" "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" ) -const flowProjectionOwnershipSchema = 1 +const flowProjectionOwnershipSchema = 2 // FlowProjectionOwnership is kernel-owned provenance stored in Git worktree // metadata. Repository artifacts describe outputs but do not authorize their // replacement or retirement. type FlowProjectionOwnership struct { - SchemaVersion int `json:"schema_version"` - SourcePath string `json:"source_path"` - ArtifactPath string `json:"artifact_path"` - ArtifactSHA256 string `json:"artifact_sha256"` - GeneratedSkills map[string]string `json:"generated_skills"` + SchemaVersion int `json:"schema_version"` + SourcePath string `json:"source_path"` + ArtifactPath string `json:"artifact_path"` + ArtifactSHA256 string `json:"artifact_sha256"` + ProjectionSelectionFingerprint string `json:"projection_selection_fingerprint"` + GeneratedProjections map[string]string `json:"generated_projections"` } type FlowProjectionOwnershipSnapshot struct { @@ -67,14 +71,15 @@ func LoadFlowProjectionOwnership(repository, sourcePath string) (FlowProjectionO return snapshot, nil } -func NewFlowProjectionOwnership(sourcePath, artifactPath string, artifact []byte, skills map[string][]byte) FlowProjectionOwnership { - generated := make(map[string]string, len(skills)) - for path, content := range skills { +func NewFlowProjectionOwnership(sourcePath, artifactPath string, artifact []byte, selectionFingerprint string, projections map[string][]byte) FlowProjectionOwnership { + generated := make(map[string]string, len(projections)) + for path, content := range projections { generated[filepath.ToSlash(path)] = projectionDigest(content) } return FlowProjectionOwnership{ SchemaVersion: flowProjectionOwnershipSchema, SourcePath: filepath.ToSlash(sourcePath), - ArtifactPath: filepath.ToSlash(artifactPath), ArtifactSHA256: projectionDigest(artifact), GeneratedSkills: generated, + ArtifactPath: filepath.ToSlash(artifactPath), ArtifactSHA256: projectionDigest(artifact), + ProjectionSelectionFingerprint: selectionFingerprint, GeneratedProjections: generated, } } @@ -91,6 +96,83 @@ func ApplyOwnedFlowProjection(repository string, writes []ProjectionWrite, remov return applyFlowProjectionWithOwnership(repository, writes, removals, expectations, projectionHooks{}, &flowProjectionOwnershipChange{prior: prior, next: next}) } +// SharedFlowProjectionReferenced reports whether another Flow ownership record +// binds an exact shared checkout-metadata file. The caller must serialize this +// observation with mutation by holding the worktree projection lease. +func SharedFlowProjectionReferenced(repository, path, expectedSHA256 string) (bool, error) { + return sharedProjectionReferenced(repository, path, expectedSHA256, "", false) +} + +func sharedProjectionReferenced(repository, path, expectedSHA256, excludeSource string, includeMaintenance bool) (bool, error) { + if !hostprojection.IsSharedCheckoutPath(path) || !hostprojection.ValidSHA256(expectedSHA256) { + return false, fmt.Errorf("FLOW_PROJECTION_SHARED_OWNERSHIP_INVALID: invalid shared projection binding") + } + gitDirectory, err := projectionGitDirectory(repository) + if err != nil { + return false, err + } + directory := filepath.Join(gitDirectory, "boatstack-flow-projections") + entries, err := os.ReadDir(directory) + if err != nil && !os.IsNotExist(err) { + return false, err + } + if err == nil { + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + raw, readErr := os.ReadFile(filepath.Join(directory, entry.Name())) + if readErr != nil { + return false, readErr + } + var identity struct { + SourcePath string `json:"source_path"` + } + if jsonErr := json.Unmarshal(raw, &identity); jsonErr != nil || identity.SourcePath == "" { + return false, fmt.Errorf("FLOW_PROJECTION_SHARED_OWNERSHIP_INVALID: malformed Flow ownership record") + } + record, decodeErr := decodeFlowProjectionOwnership(raw, identity.SourcePath) + if decodeErr != nil { + return false, decodeErr + } + if record.SourcePath == excludeSource { + continue + } + if digest, referenced := record.GeneratedProjections[path]; referenced { + if digest != expectedSHA256 { + return false, fmt.Errorf("FLOW_PROJECTION_SHARED_OWNERSHIP_INVALID: shared projection owners disagree on %s", path) + } + return true, nil + } + } + } + if !includeMaintenance { + return false, nil + } + raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "host-projections.json")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + var manifest struct { + SchemaVersion int `json:"schema_version"` + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(raw, &manifest); err != nil || manifest.SchemaVersion != 2 || manifest.Files == nil { + return false, fmt.Errorf("FLOW_PROJECTION_SHARED_OWNERSHIP_INVALID: malformed maintenance projection manifest") + } + if digest, referenced := manifest.Files[path]; referenced { + if digest != expectedSHA256 { + return false, fmt.Errorf("FLOW_PROJECTION_SHARED_OWNERSHIP_INVALID: maintenance and Flow owners disagree on %s", path) + } + return true, nil + } + return false, nil +} + // AcquireFlowProjectionLease serializes Flow effect execution with official // projection publication for one Git worktree. func AcquireFlowProjectionLease(repository string) (*FlowProjectionLease, error) { @@ -132,11 +214,11 @@ func decodeFlowProjectionOwnership(raw []byte, sourcePath string) (FlowProjectio if err := decoder.Decode(&struct{}{}); err != io.EOF { return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: trailing data") } - if record.SchemaVersion != flowProjectionOwnershipSchema || record.SourcePath != filepath.ToSlash(sourcePath) || !safeProjectionRelative(record.ArtifactPath) || len(record.ArtifactSHA256) != 64 || record.GeneratedSkills == nil { + if record.SchemaVersion != flowProjectionOwnershipSchema || record.SourcePath != filepath.ToSlash(sourcePath) || !safeProjectionRelative(record.ArtifactPath) || !hostprojection.ValidSHA256(record.ArtifactSHA256) || !hostprojection.ValidSHA256(record.ProjectionSelectionFingerprint) || record.GeneratedProjections == nil { return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance envelope is incomplete") } - for path, digest := range record.GeneratedSkills { - if !safeProjectionRelative(path) || len(digest) != 64 { + for path, digest := range record.GeneratedProjections { + if !hostprojection.ValidFlowPath(path) || !hostprojection.ValidSHA256(digest) { return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: generated output binding is invalid") } } @@ -144,7 +226,7 @@ func decodeFlowProjectionOwnership(raw []byte, sourcePath string) (FlowProjectio } func safeProjectionRelative(value string) bool { - if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) { + if value == "" || filepath.IsAbs(value) || strings.ContainsAny(value, "\\\r\n\x00") { return false } clean := filepath.Clean(filepath.FromSlash(value)) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 9799a4b..6797a82 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -111,6 +112,7 @@ type publicationPreview struct { func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) { var mutations []ports.ResourceMutation + var selectedProjections []hostprojection.ID var deliveryID string if transitionUsesDeliveryArtifacts(transition.ID) { var err error @@ -147,6 +149,10 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio state.ExternalEffectPolicy = policy.ExternalEffectAuthority state.IndependentReview = policy.IndependentReviewForHighRisk state.EnabledHosts = append([]string(nil), policy.Hosts...) + selectedProjections, decodeErr = config.ProjectionIDs() + if decodeErr != nil { + return nil, decodeErr + } case "plan.create", "plan.amend": source, _ := admission.Parameters.Get("source_path") expected, _ := admission.Parameters.Get("source_fingerprint") @@ -428,7 +434,21 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } } if transition.ID == "configuration.initialize" || transition.ID == "configuration.mutate" || transition.ID == "installation.initialize" || transition.ID == "installation.update" || transition.ID == "installation.reconcile-update" { - hostMutations, hostErr := prepareHostSkillMutations(layout.RepositoryRoot, state.EnabledHosts) + if selectedProjections == nil { + raw, readErr := os.ReadFile(layout.ConfigPath) + if readErr != nil { + return nil, fmt.Errorf("read current project configuration for host projections: %w", readErr) + } + config, decodeErr := protocol.DecodeProjectConfig(raw) + if decodeErr != nil { + return nil, decodeErr + } + selectedProjections, decodeErr = config.ProjectionIDs() + if decodeErr != nil { + return nil, decodeErr + } + } + hostMutations, hostErr := prepareHostProjectionMutations(layout.RepositoryRoot, selectedProjections) if hostErr != nil { return nil, hostErr } diff --git a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go index 0577271..4ce6af3 100644 --- a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go @@ -50,7 +50,7 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestProgramChangeInvalidatesPriorPrescriptionBeforeEffects(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 7c22871..6890ad6 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -51,7 +51,7 @@ func writeBoundaryConfig(t *testing.T, command string) ports.ControllerLayout { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } - raw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) + raw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/host_skills.go b/boatstack/internal/softwaredelivery/effects/host_projections.go similarity index 56% rename from boatstack/internal/softwaredelivery/effects/host_skills.go rename to boatstack/internal/softwaredelivery/effects/host_projections.go index 86b4e3a..549c6d7 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills.go +++ b/boatstack/internal/softwaredelivery/effects/host_projections.go @@ -1,24 +1,30 @@ package effects import ( + "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" "strings" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" ) -const hostSkillManifestSchema = 1 +const hostProjectionManifestSchema = 2 -type hostSkillManifest struct { - SchemaVersion int `json:"schema_version"` - Files map[string]string `json:"files"` +type hostProjectionManifest struct { + SchemaVersion int `json:"schema_version"` + Projections []string `json:"projections"` + ProjectionSelectionFingerprint string `json:"projection_selection_fingerprint"` + Files map[string]string `json:"files"` } -type hostSkillMode struct { +type hostProjectionMode struct { Slug string DisplayName string Description string @@ -27,7 +33,7 @@ type hostSkillMode struct { AuthorityContract string } -var hostSkillModes = []hostSkillMode{ +var hostProjectionModes = []hostProjectionMode{ { Slug: "boatstack-update", DisplayName: "Boatstack Update", Description: "Apply a checksum-verified Boatstack update.", @@ -52,7 +58,7 @@ through that rollback, preserve its complete receipt, and retry once from the restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier.` -func renderHostSkill(mode hostSkillMode) []byte { +func renderHostProjection(mode hostProjectionMode, projection hostprojection.ID) []byte { return []byte(fmt.Sprintf(`--- name: %s description: %s Use only when the user explicitly selects this Boatstack operation. @@ -62,6 +68,8 @@ description: %s Use only when the user explicitly selects this Boatstack operati Select %s. %s +For every Boatstack command in this driver that accepts a host, pass `+"`--host %s`"+`. + Run `+ "`boatstack status --repo . --format json`"+` once for observation. An authority-free `+"`FRONTIER`"+` from status is diagnostic only and cannot terminate this selected operation. @@ -95,10 +103,10 @@ authority-bearing `+"`FRONTIER`"+`, `+"`BLOCKED`"+`, `+"`REFUSED`"+`, or If recovery is active, use only a transition in `+"`recovery_info.permitted`"+` and the exact transaction ID. Never choose maintenance, correction, abandonment, merge, provider, or destructive authority as an escape from a frontier. -`, mode.Slug, mode.Description, mode.DisplayName, mode.Target, mode.Extra, mode.AuthorityContract)) +`, mode.Slug, mode.Description, mode.DisplayName, mode.Target, mode.Extra, projection, mode.AuthorityContract)) } -func renderOpenAIMetadata(mode hostSkillMode) []byte { +func renderOpenAIMetadata(mode hostProjectionMode) []byte { return []byte(fmt.Sprintf(`interface: display_name: %q short_description: %q @@ -108,21 +116,28 @@ policy: `, mode.DisplayName, mode.Description, "Use $"+mode.Slug+" to follow the authority-preserving Boatstack driver.")) } -func desiredHostSkillFiles(hosts []string) map[string][]byte { +func desiredHostProjectionFiles(projections []hostprojection.ID) map[string][]byte { desired := map[string][]byte{} - for _, host := range hosts { - for _, mode := range hostSkillModes { - skill := renderHostSkill(mode) - switch host { - case "codex": + for _, projection := range projections { + for _, mode := range hostProjectionModes { + skill := renderHostProjection(mode, projection) + switch projection { + case hostprojection.Codex: root := filepath.ToSlash(filepath.Join(".agents", "skills", mode.Slug)) + desired[root+"/.gitattributes"] = []byte("* -text\n") desired[root+"/SKILL.md"] = skill desired[root+"/agents/openai.yaml"] = renderOpenAIMetadata(mode) - case "claude": - desired[filepath.ToSlash(filepath.Join(".claude", "skills", mode.Slug, "SKILL.md"))] = skill - case "gemini": + case hostprojection.Claude: + root := filepath.ToSlash(filepath.Join(".claude", "skills", mode.Slug)) + desired[root+"/.gitattributes"] = []byte("* -text\n") + desired[root+"/SKILL.md"] = skill + case hostprojection.Gemini: + attributePath, attributeContent, _ := hostprojection.SharedCheckoutPath(projection) + desired[attributePath] = attributeContent desired[filepath.ToSlash(filepath.Join(".gemini", "skills", mode.Slug, "SKILL.md"))] = skill - case "cursor": + case hostprojection.Cursor: + attributePath, attributeContent, _ := hostprojection.SharedCheckoutPath(projection) + desired[attributePath] = attributeContent desired[filepath.ToSlash(filepath.Join(".cursor", "commands", mode.Slug+".md"))] = skill } } @@ -130,11 +145,19 @@ func desiredHostSkillFiles(hosts []string) map[string][]byte { return desired } -// ProjectedHostSkillFiles returns the exact runtime-owned host projection and +// ProjectedHostProjectionFiles returns the exact runtime-owned host projection and // manifest bytes without mutating a repository. -func ProjectedHostSkillFiles(hosts []string) (map[string][]byte, []byte, error) { - desired := desiredHostSkillFiles(hosts) - manifest := hostSkillManifest{SchemaVersion: hostSkillManifestSchema, Files: map[string]string{}} +func ProjectedHostProjectionFiles(projections []hostprojection.ID) (map[string][]byte, []byte, error) { + canonical, err := hostprojection.ParseIDs(hostprojection.Strings(projections)) + if err != nil { + return nil, nil, err + } + desired := desiredHostProjectionFiles(canonical) + selectionFingerprint, err := hostprojection.SelectionFingerprint(canonical) + if err != nil { + return nil, nil, err + } + manifest := hostProjectionManifest{SchemaVersion: hostProjectionManifestSchema, Projections: hostprojection.Strings(canonical), ProjectionSelectionFingerprint: selectionFingerprint, Files: map[string]string{}} for path, raw := range desired { manifest.Files[path] = sha256Bytes(raw) } @@ -145,22 +168,30 @@ func ProjectedHostSkillFiles(hosts []string) (map[string][]byte, []byte, error) return desired, append(manifestRaw, '\n'), nil } -func prepareHostSkillMutations(repository string, hosts []string) ([]ports.ResourceMutation, error) { - desired := desiredHostSkillFiles(hosts) - manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") +func prepareHostProjectionMutations(repository string, projections []hostprojection.ID) ([]ports.ResourceMutation, error) { + canonical, err := hostprojection.ParseIDs(hostprojection.Strings(projections)) + if err != nil { + return nil, err + } + desired := desiredHostProjectionFiles(canonical) + selectionFingerprint, err := hostprojection.SelectionFingerprint(canonical) + if err != nil { + return nil, err + } + manifestPath := filepath.Join(repository, ".boatstack", "host-projections.json") manifestRaw, manifestExists, _, err := readAllIfExists(manifestPath) if err != nil { return nil, err } - prior := hostSkillManifest{Files: map[string]string{}} + prior := hostProjectionManifest{Projections: []string{}, Files: map[string]string{}} if manifestExists { - if err := json.Unmarshal(manifestRaw, &prior); err != nil || prior.SchemaVersion != hostSkillManifestSchema || prior.Files == nil { - return nil, fmt.Errorf("Boatstack host-skill manifest is malformed") + if err := decodeHostProjectionManifest(manifestRaw, &prior); err != nil { + return nil, fmt.Errorf("Boatstack host-projection manifest is malformed") } } for relative, expected := range prior.Files { - absolute, pathErr := managedHostSkillPath(repository, relative) + absolute, pathErr := managedHostProjectionPath(repository, relative) if pathErr != nil { return nil, pathErr } @@ -169,7 +200,7 @@ func prepareHostSkillMutations(repository string, hosts []string) ([]ports.Resou return nil, readErr } if !exists || sha256Bytes(current) != expected { - return nil, fmt.Errorf("Boatstack host skill %s changed outside the managed projection", relative) + return nil, fmt.Errorf("Boatstack host projection %s changed outside the managed projection", relative) } } @@ -179,9 +210,9 @@ func prepareHostSkillMutations(repository string, hosts []string) ([]ports.Resou paths = append(paths, relative) } sort.Strings(paths) - next := hostSkillManifest{SchemaVersion: hostSkillManifestSchema, Files: map[string]string{}} + next := hostProjectionManifest{SchemaVersion: hostProjectionManifestSchema, Projections: hostprojection.Strings(canonical), ProjectionSelectionFingerprint: selectionFingerprint, Files: map[string]string{}} for _, relative := range paths { - absolute, pathErr := managedHostSkillPath(repository, relative) + absolute, pathErr := managedHostProjectionPath(repository, relative) if pathErr != nil { return nil, pathErr } @@ -190,7 +221,7 @@ func prepareHostSkillMutations(repository string, hosts []string) ([]ports.Resou return nil, readErr } if exists && !manifestExists && !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { - return nil, fmt.Errorf("unmanaged file collides with Boatstack host skill %s", relative) + return nil, fmt.Errorf("unmanaged file collides with Boatstack host projection %s", relative) } if !exists || !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { mutation, mutationErr := mutationFor(absolute, desired[relative], 0o644, false, false) @@ -206,7 +237,16 @@ func prepareHostSkillMutations(repository string, hosts []string) ([]ports.Resou if _, keep := desired[relative]; keep { continue } - absolute, pathErr := managedHostSkillPath(repository, relative) + if hostprojection.IsSharedCheckoutPath(relative) { + referenced, referenceErr := boatstackruntime.SharedFlowProjectionReferenced(repository, relative, prior.Files[relative]) + if referenceErr != nil { + return nil, referenceErr + } + if referenced { + continue + } + } + absolute, pathErr := managedHostProjectionPath(repository, relative) if pathErr != nil { return nil, pathErr } @@ -231,10 +271,50 @@ func prepareHostSkillMutations(repository string, hosts []string) ([]ports.Resou return mutations, nil } -func managedHostSkillPath(repository, relative string) (string, error) { +func managedHostProjectionPath(repository, relative string) (string, error) { clean := filepath.Clean(filepath.FromSlash(relative)) - if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { - return "", fmt.Errorf("invalid managed host-skill path %q", relative) + if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || !hostprojection.ValidMaintenancePath(filepath.ToSlash(clean)) { + return "", fmt.Errorf("invalid managed host-projection path %q", relative) } return filepath.Join(repository, clean), nil } + +func decodeHostProjectionManifest(raw []byte, manifest *hostProjectionManifest) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(manifest); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("trailing manifest data") + } + if manifest.SchemaVersion != hostProjectionManifestSchema || manifest.Projections == nil || manifest.Files == nil { + return fmt.Errorf("incomplete manifest") + } + projections, err := hostprojection.ParseIDs(manifest.Projections) + if err != nil || !equalProjectionStrings(manifest.Projections, hostprojection.Strings(projections)) { + return fmt.Errorf("non-canonical projections") + } + fingerprint, err := hostprojection.SelectionFingerprint(projections) + if err != nil || fingerprint != manifest.ProjectionSelectionFingerprint { + return fmt.Errorf("projection fingerprint mismatch") + } + for path, fingerprint := range manifest.Files { + if !hostprojection.ValidMaintenancePath(path) || !hostprojection.ValidSHA256(fingerprint) { + return fmt.Errorf("invalid projection file binding") + } + } + return nil +} + +func equalProjectionStrings(one, two []string) bool { + if len(one) != len(two) { + return false + } + for index := range one { + if one[index] != two[index] { + return false + } + } + return true +} diff --git a/boatstack/internal/softwaredelivery/effects/host_skills_test.go b/boatstack/internal/softwaredelivery/effects/host_projections_test.go similarity index 52% rename from boatstack/internal/softwaredelivery/effects/host_skills_test.go rename to boatstack/internal/softwaredelivery/effects/host_projections_test.go index e3763c0..4e0979c 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills_test.go +++ b/boatstack/internal/softwaredelivery/effects/host_projections_test.go @@ -2,21 +2,23 @@ package effects import ( "context" + "encoding/json" "os" "path/filepath" "strings" "testing" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" ) -func TestHostSkillProjectionExposesOnlyKernelMaintenance(t *testing.T) { +func TestHostProjectionProjectionExposesOnlyKernelMaintenance(t *testing.T) { // control-law: repository-flow-entries-not-kernel-modes - files := desiredHostSkillFiles([]string{"cli", "cursor", "codex", "claude", "gemini", "mcp"}) + files := desiredHostProjectionFiles(hostprojection.CanonicalIDs()) counts := map[string]int{} for path, raw := range files { - for _, mode := range hostSkillModes { + for _, mode := range hostProjectionModes { if strings.Contains(path, mode.Slug) && strings.HasSuffix(path, "SKILL.md") || strings.HasSuffix(path, mode.Slug+".md") { if !strings.Contains(string(raw), "name: "+mode.Slug) || !strings.Contains(string(raw), mode.Target) { t.Fatalf("%s does not bind %s to %s", path, mode.Slug, mode.Target) @@ -26,11 +28,11 @@ func TestHostSkillProjectionExposesOnlyKernelMaintenance(t *testing.T) { switch { case strings.HasPrefix(path, ".agents/") && strings.HasSuffix(path, "/SKILL.md"): counts["codex"]++ - case strings.HasPrefix(path, ".claude/"): + case strings.HasPrefix(path, ".claude/") && strings.HasSuffix(path, "/SKILL.md"): counts["claude"]++ - case strings.HasPrefix(path, ".gemini/"): + case strings.HasPrefix(path, ".gemini/") && strings.HasSuffix(path, "/SKILL.md"): counts["gemini"]++ - case strings.HasPrefix(path, ".cursor/"): + case strings.HasPrefix(path, ".cursor/") && strings.HasSuffix(path, ".md"): counts["cursor"]++ } } @@ -41,11 +43,62 @@ func TestHostSkillProjectionExposesOnlyKernelMaintenance(t *testing.T) { } } -func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) { +func TestMaintenanceProjectionSelectionCoversAllAndNone(t *testing.T) { + all, manifestRaw, err := ProjectedHostProjectionFiles(hostprojection.CanonicalIDs()) + if err != nil { + t.Fatal(err) + } + if len(all) != 9 { + t.Fatalf("all maintenance files = %d, want 9: %v", len(all), all) + } + for _, id := range hostprojection.CanonicalIDs() { + paths, _ := hostprojection.MaintenancePaths(id) + for _, path := range paths { + raw, exists := all[path] + if !exists { + t.Fatalf("%s projection is missing %s", id, path) + } + if !strings.HasSuffix(path, ".gitattributes") && !strings.HasSuffix(path, "openai.yaml") && !strings.Contains(string(raw), "--host "+string(id)) { + t.Fatalf("%s does not bind its matching host", path) + } + } + } + var manifest hostProjectionManifest + if err := decodeHostProjectionManifest(manifestRaw, &manifest); err != nil { + t.Fatal(err) + } + if len(manifest.Projections) != 4 || len(manifest.ProjectionSelectionFingerprint) != 64 { + t.Fatalf("manifest selection = %#v", manifest) + } + for _, id := range hostprojection.CanonicalIDs() { + selected, selectedManifestRaw, selectedErr := ProjectedHostProjectionFiles([]hostprojection.ID{id}) + paths, _ := hostprojection.MaintenancePaths(id) + if selectedErr != nil || len(selected) != len(paths) { + t.Fatalf("%s-only maintenance projection = %v, %v", id, selected, selectedErr) + } + var selectedManifest hostProjectionManifest + if decodeErr := decodeHostProjectionManifest(selectedManifestRaw, &selectedManifest); decodeErr != nil || len(selectedManifest.Projections) != 1 || selectedManifest.Projections[0] != string(id) { + t.Fatalf("%s-only manifest = %#v, %v", id, selectedManifest, decodeErr) + } + } + none, noneManifestRaw, err := ProjectedHostProjectionFiles([]hostprojection.ID{}) + if err != nil { + t.Fatal(err) + } + if len(none) != 0 { + t.Fatalf("empty selection generated files: %v", none) + } + var noneManifest hostProjectionManifest + if err := decodeHostProjectionManifest(noneManifestRaw, &noneManifest); err != nil || len(noneManifest.Projections) != 0 || len(noneManifest.Files) != 0 { + t.Fatalf("empty manifest = %#v, %v", noneManifest, err) + } +} + +func TestHostProjectionProjectionPreservesAuthorityBoundaries(t *testing.T) { // control-law: operation-trigger-selects-target-without-broadening-authority - for path, raw := range desiredHostSkillFiles([]string{"cursor", "codex", "claude", "gemini"}) { + for path, raw := range desiredHostProjectionFiles(hostprojection.CanonicalIDs()) { value := string(raw) - if strings.HasSuffix(path, "openai.yaml") { + if strings.HasSuffix(path, "openai.yaml") || strings.HasSuffix(path, ".gitattributes") { continue } for _, contract := range []string{ @@ -63,11 +116,11 @@ func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) { } } -func TestHostSkillProjectionDoesNotClaimDeliveryEntries(t *testing.T) { +func TestHostProjectionProjectionDoesNotClaimDeliveryEntries(t *testing.T) { // control-law: kernel-skill-projection-cannot-invent-repository-flow-entries - files := desiredHostSkillFiles([]string{"cursor", "codex", "claude", "gemini"}) + files := desiredHostProjectionFiles(hostprojection.CanonicalIDs()) for path, raw := range files { - if strings.HasSuffix(path, "openai.yaml") { + if strings.HasSuffix(path, "openai.yaml") || strings.HasSuffix(path, ".gitattributes") { continue } value := string(raw) @@ -91,10 +144,10 @@ func TestHostSkillProjectionDoesNotClaimDeliveryEntries(t *testing.T) { } } -func TestHostSkillProjectionInventoriesEveryDriverPath(t *testing.T) { +func TestHostProjectionProjectionInventoriesEveryDriverPath(t *testing.T) { // control-law: generated-driver-event-slice-is-complete - for path, raw := range desiredHostSkillFiles([]string{"cursor", "codex", "claude", "gemini"}) { - if strings.HasSuffix(path, "openai.yaml") { + for path, raw := range desiredHostProjectionFiles(hostprojection.CanonicalIDs()) { + if strings.HasSuffix(path, "openai.yaml") || strings.HasSuffix(path, ".gitattributes") { continue } value := string(raw) @@ -108,7 +161,7 @@ func TestHostSkillProjectionInventoriesEveryDriverPath(t *testing.T) { } } -func TestHostSkillProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { +func TestHostProjectionProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { // control-law: unmanaged-host-file-cannot-be-overwritten-by-installation repository := t.TempDir() path := filepath.Join(repository, ".agents", "skills", "boatstack-update", "SKILL.md") @@ -118,7 +171,7 @@ func TestHostSkillProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { if err := os.WriteFile(path, []byte("user owned\n"), 0o644); err != nil { t.Fatal(err) } - if _, err := prepareHostSkillMutations(repository, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "unmanaged file collides") { + if _, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex}); err == nil || !strings.Contains(err.Error(), "unmanaged file collides") { t.Fatalf("collision was not rejected: %v", err) } if raw, err := os.ReadFile(path); err != nil || string(raw) != "user owned\n" { @@ -126,10 +179,33 @@ func TestHostSkillProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { } } -func TestHostSkillProjectionRejectsManagedDrift(t *testing.T) { +func TestHostProjectionManifestRejectsUnknownFieldsAndSelectionDrift(t *testing.T) { + files, raw, err := ProjectedHostProjectionFiles([]hostprojection.ID{hostprojection.Codex}) + if err != nil || len(files) == 0 { + t.Fatalf("projection fixture = %v, %v", files, err) + } + unknown := []byte(strings.Replace(string(raw), `"files"`, `"unknown":true,"files"`, 1)) + var manifest hostProjectionManifest + if err := decodeHostProjectionManifest(unknown, &manifest); err == nil { + t.Fatal("unknown manifest field was accepted") + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + manifest.Projections = []string{} + forged, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := decodeHostProjectionManifest(forged, &manifest); err == nil { + t.Fatal("selection fingerprint drift was accepted") + } +} + +func TestHostProjectionProjectionRejectsManagedDrift(t *testing.T) { // control-law: manifest-binds-update-bytes repository := t.TempDir() - mutations, err := prepareHostSkillMutations(repository, []string{"codex", "gemini"}) + mutations, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex, hostprojection.Gemini}) if err != nil { t.Fatal(err) } @@ -138,15 +214,18 @@ func TestHostSkillProjectionRejectsManagedDrift(t *testing.T) { if err := os.WriteFile(gemini, []byte("drift\n"), 0o644); err != nil { t.Fatal(err) } - if _, err := prepareHostSkillMutations(repository, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "changed outside") { + if _, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex}); err == nil || !strings.Contains(err.Error(), "changed outside") { t.Fatalf("managed drift was not rejected: %v", err) } } -func TestHostSkillProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { +func TestHostProjectionProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { // control-law: host-removal-deletes-only-manifest-owned-projections repository := t.TempDir() - mutations, err := prepareHostSkillMutations(repository, []string{"codex", "gemini"}) + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o700); err != nil { + t.Fatal(err) + } + mutations, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex, hostprojection.Gemini}) if err != nil { t.Fatal(err) } @@ -158,7 +237,7 @@ func TestHostSkillProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { if err := os.WriteFile(unmanaged, []byte("unrelated\n"), 0o644); err != nil { t.Fatal(err) } - mutations, err = prepareHostSkillMutations(repository, []string{"codex"}) + mutations, err = prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex}) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index ec8bfc0..928702f 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -237,7 +237,7 @@ func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -302,7 +302,7 @@ func TestControllerRejectsExactBundleRevisionDriftWithMatchingWorkingBytes(t *te runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -585,7 +585,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) - initialConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + initialConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") initialPath := filepath.Join(t.TempDir(), "initial.json") if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) @@ -616,7 +616,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing t.Fatalf("detached layout did not select external config: %#v", detachedLayout) } apply("engagement.begin", protocol.AuthorityBundle{}, true, nil) - updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"updated-operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + updatedConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"updated-operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") updatedPath := filepath.Join(t.TempDir(), "updated.json") if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil { t.Fatal(err) @@ -685,7 +685,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -940,7 +940,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1072,7 +1072,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1085,7 +1085,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes apply(approvedObjective, "engagement.begin", authority(catalog.AuthorityRepository), nil) updatedConfigPath := filepath.Join(t.TempDir(), "project-v2-updated.json") - updatedConfig := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + updatedConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(updatedConfigPath, updatedConfig, 0o600); err != nil { t.Fatal(err) } @@ -1206,7 +1206,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configSource := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configSource, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index a66b8f1..545bb4d 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -108,7 +108,7 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. t.Fatal(err) } configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go index e08d7c1..d6e8098 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -145,7 +145,7 @@ func identityRepository(t *testing.T) string { } func identityConfig(actor string) []byte { - return []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"` + actor + `"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"]}`) + return []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"` + actor + `"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) } func writeIdentityFile(t *testing.T, path string, raw []byte) { diff --git a/boatstack/internal/softwaredelivery/protocol/config.go b/boatstack/internal/softwaredelivery/protocol/config.go index 3ec826d..a1823f9 100644 --- a/boatstack/internal/softwaredelivery/protocol/config.go +++ b/boatstack/internal/softwaredelivery/protocol/config.go @@ -11,11 +11,12 @@ import ( "regexp" "sort" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ConfigSchemaVersion = 3 +const ConfigSchemaVersion = 4 type ProjectSettings struct { Name string `json:"name"` @@ -56,6 +57,7 @@ type ProjectConfig struct { Project ProjectSettings `json:"project"` Policy PolicySettings `json:"policy"` Hosts []string `json:"hosts"` + Projections []string `json:"projections"` Extensions []SubprocessExtensionSettings `json:"extensions,omitempty"` } @@ -64,6 +66,20 @@ var extensionID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$`) func CanonicalHosts() []string { return append([]string(nil), canonicalHosts...) } +func CanonicalProjections() []string { return hostprojection.CanonicalStrings() } + +func (c ProjectConfig) ProjectionIDs() ([]hostprojection.ID, error) { + return hostprojection.Parse(c.Projections, c.Hosts) +} + +func (c ProjectConfig) ProjectionSelectionFingerprint() (string, error) { + projections, err := c.ProjectionIDs() + if err != nil { + return "", err + } + return hostprojection.SelectionFingerprint(projections) +} + func (c ProjectConfig) ControlPolicy() model.ConfigurationPolicy { external := c.Policy.ExternalEffectAuthority if external == "" { @@ -92,7 +108,7 @@ func DecodeProjectConfig(value []byte) (ProjectConfig, error) { return config, nil } -// ProjectConfigFingerprint binds configuration authority to strict schema-3 +// ProjectConfigFingerprint binds configuration authority to strict schema-4 // semantics rather than checkout-specific JSON bytes. Formatting, object-key // order, line endings, the defaulted external-effect policy, and host ordering // therefore cannot make an otherwise identical configuration stale. @@ -104,6 +120,8 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { canonical := config canonical.Hosts = append([]string(nil), config.Hosts...) sort.Strings(canonical.Hosts) + canonical.Projections = append([]string(nil), config.Projections...) + sort.Strings(canonical.Projections) canonical.Extensions = append([]SubprocessExtensionSettings(nil), config.Extensions...) for index := range canonical.Extensions { values := []struct { @@ -139,7 +157,7 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { func (c ProjectConfig) Validate() error { if c.SchemaVersion != ConfigSchemaVersion || c.Project.Name == "" || c.Project.DefaultBranch == "" || c.Project.Commands == nil { - return fmt.Errorf("Boatstack project configuration requires schema 3, project name, default branch, commands, and human identity") + return fmt.Errorf("Boatstack project configuration requires schema 4, project name, default branch, commands, and human identity") } if err := c.Identity.Human.Validate(); err != nil { return err @@ -174,6 +192,9 @@ func (c ProjectConfig) Validate() error { if !seen["cli"] { return fmt.Errorf("Boatstack project configuration must enable the canonical CLI surface") } + if _, err := hostprojection.Parse(c.Projections, c.Hosts); err != nil { + return err + } seenExtensions := map[string]bool{} for _, extension := range c.Extensions { if !extensionID.MatchString(extension.ID) || extension.Version == "" || !filepath.IsAbs(extension.Executable) || filepath.Clean(extension.Executable) != extension.Executable || len(extension.SHA256) != 64 || len(extension.Manifest) == 0 { diff --git a/boatstack/internal/softwaredelivery/protocol/config_test.go b/boatstack/internal/softwaredelivery/protocol/config_test.go index bda719f..5dbc0d6 100644 --- a/boatstack/internal/softwaredelivery/protocol/config_test.go +++ b/boatstack/internal/softwaredelivery/protocol/config_test.go @@ -3,6 +3,7 @@ package protocol import ( "encoding/json" "path/filepath" + "reflect" "strings" "testing" @@ -16,16 +17,18 @@ func literalIdentity() IdentitySettings { } func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) { - valid := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + valid := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) if _, err := DecodeProjectConfig(valid); err != nil { t.Fatal(err) } invalid := [][]byte{ - []byte(`{"schema_version":2,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":3,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"]}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"legacy":true}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[],"legacy":true}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":null}`), } for _, value := range invalid { if _, err := DecodeProjectConfig(value); err == nil { @@ -43,6 +46,7 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * Project: ProjectSettings{Name: "product", DefaultBranch: "main", Commands: map[string]string{}}, Policy: PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli", "sdk"}, + Projections: []string{}, Extensions: []SubprocessExtensionSettings{{ ID: "example.guard", Version: "1.0.0", Executable: executable, SHA256: strings.Repeat("a", 64), Manifest: json.RawMessage(`{"id":"example.guard","version":"1.0.0","protocol_version":1,"settings_schema":{"type":"object"},"privacy_classification":"metadata-only","telemetry_classification":"transition-receipt"}`), @@ -92,8 +96,8 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * } func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { - one := []byte("{\n \"schema_version\": 3,\n \"identity\": {\"human\": {\"kind\": \"literal\", \"value\": \"operator\"}},\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"]\n}\n") - two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"identity\":{\"human\":{\"value\":\"operator\",\"kind\":\"literal\"}},\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":3\r\n}\r\n") + one := []byte("{\n \"schema_version\": 4,\n \"identity\": {\"human\": {\"kind\": \"literal\", \"value\": \"operator\"}},\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"],\n \"projections\": [\"codex\"]\n}\n") + two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"projections\":[\"codex\"],\r\n\"identity\":{\"human\":{\"value\":\"operator\",\"kind\":\"literal\"}},\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":4\r\n}\r\n") _, oneFingerprint, err := ProjectConfigFingerprint(one) if err != nil { t.Fatal(err) @@ -106,7 +110,7 @@ func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { t.Fatalf("representation changed semantic fingerprint: %s != %s", oneFingerprint, twoFingerprint) } - changed := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"]}`) + changed := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"],"projections":["codex"]}`) _, changedFingerprint, err := ProjectConfigFingerprint(changed) if err != nil { t.Fatal(err) @@ -122,9 +126,81 @@ func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { } } +func TestProjectProjectionsAreExplicitCanonicalAndNonsemantic(t *testing.T) { + base := ProjectConfig{ + SchemaVersion: ConfigSchemaVersion, + Identity: literalIdentity(), + Project: ProjectSettings{Name: "product", DefaultBranch: "main", Commands: map[string]string{}}, + Policy: PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, + Hosts: []string{"cli", "codex", "claude", "cursor", "gemini"}, + Projections: []string{"cursor", "codex"}, + } + raw, _ := json.Marshal(base) + config, fingerprint, err := ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + selection, err := config.ProjectionSelectionFingerprint() + if err != nil || len(selection) != 64 { + t.Fatalf("selection fingerprint = %q, %v", selection, err) + } + reordered := base + reordered.Hosts = []string{"gemini", "cursor", "claude", "codex", "cli"} + reordered.Projections = []string{"codex", "cursor"} + raw, _ = json.Marshal(reordered) + _, reorderedFingerprint, err := ProjectConfigFingerprint(raw) + if err != nil || reorderedFingerprint != fingerprint { + t.Fatalf("reordered fingerprint = %q, %v, want %q", reorderedFingerprint, err, fingerprint) + } + + changed := base + changed.Projections = []string{"codex"} + raw, _ = json.Marshal(changed) + changedConfig, changedFingerprint, err := ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + if changedFingerprint == fingerprint { + t.Fatal("projection membership retained project configuration fingerprint") + } + if !reflect.DeepEqual(config.ControlPolicy(), changedConfig.ControlPolicy()) { + t.Fatalf("projection membership changed runtime policy: %#v != %#v", config.ControlPolicy(), changedConfig.ControlPolicy()) + } + + for _, projection := range []string{"codex", "claude", "cursor", "gemini"} { + candidate := base + candidate.Projections = []string{projection} + raw, _ = json.Marshal(candidate) + if _, err := DecodeProjectConfig(raw); err != nil { + t.Fatalf("valid projection %q: %v", projection, err) + } + } + for _, projection := range []string{"cli", "mcp", "sdk", "unknown"} { + candidate := base + candidate.Projections = []string{projection} + raw, _ = json.Marshal(candidate) + if _, err := DecodeProjectConfig(raw); err == nil { + t.Fatalf("invalid projection %q was accepted", projection) + } + } + withoutHost := base + withoutHost.Hosts = []string{"cli", "codex"} + withoutHost.Projections = []string{"cursor"} + raw, _ = json.Marshal(withoutHost) + if _, err := DecodeProjectConfig(raw); err == nil || !strings.Contains(err.Error(), "PROJECT_PROJECTION_HOST_DISABLED") { + t.Fatalf("disabled-host projection error = %v", err) + } + empty := base + empty.Projections = []string{} + raw, _ = json.Marshal(empty) + if _, err := DecodeProjectConfig(raw); err != nil { + t.Fatalf("explicit empty projections: %v", err) + } +} + func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { - literal := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"example-operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) - command := []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`) + literal := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"example-operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + command := []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) literalConfig, literalFingerprint, err := ProjectConfigFingerprint(literal) if err != nil { t.Fatal(err) @@ -140,10 +216,10 @@ func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { t.Fatal("identity descriptor change preserved project configuration fingerprint") } for _, invalid := range [][]byte{ - []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"command","command":"gh","args":null}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"actor","command":""}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"actor","unknown":true}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":null}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"actor","command":""}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"actor","unknown":true}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), } { if _, err := DecodeProjectConfig(invalid); err == nil { t.Fatalf("invalid identity config was accepted: %s", invalid) diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index 82fb5e1..b11c15a 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -23,6 +23,6 @@ descriptor but never executes it. Identity resolution proposes an actor; it does not grant human or external-provider authority. Configuration changes use `configuration.mutate` with `config_path` and -`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-3 +`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-4 value in canonical JSON form, not the source file's raw bytes; the CLI derives it when omitted. Never hand-edit controller state or reuse a V1 schema. diff --git a/boatstack/sdk/human_identity_test.go b/boatstack/sdk/human_identity_test.go index 1ed7058..bea1072 100644 --- a/boatstack/sdk/human_identity_test.go +++ b/boatstack/sdk/human_identity_test.go @@ -28,7 +28,7 @@ func TestSDKResponseBoundaryAttachesVerifiedHumanIdentity(t *testing.T) { if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) } - raw := []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"sdk-operator"}},"project":{"name":"sdk-fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"]}`) + raw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"sdk-operator"}},"project":{"name":"sdk-fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) configPath := filepath.Join(repository, ".boatstack", "project.json") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) diff --git a/docs/configuration.md b/docs/configuration.md index c57ea66..e5fecfa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,12 +1,12 @@ # Boatstack configuration `.boatstack/project.json` is the repository-owned policy input. Boatstack accepts only -schema version 3. Unknown top-level fields, unsupported policy values, duplicate -hosts, trailing JSON, and missing required fields fail closed. +schema version 4. Unknown top-level fields, unsupported policy values, duplicate +hosts or projections, trailing JSON, and missing required fields fail closed. ```json { - "schema_version": 3, + "schema_version": 4, "identity": { "human": { "kind": "command", @@ -30,7 +30,8 @@ hosts, trailing JSON, and missing required fields fail closed. "visual_evidence": "optional", "external_effect_authority": "human-or-autonomy-plus-provider" }, - "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp", "sdk"] + "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp", "sdk"], + "projections": ["codex", "claude", "cursor", "gemini"] } ``` @@ -41,6 +42,14 @@ hosts, trailing JSON, and missing required fields fail closed. - `policy.plan_approval`: `human` or `human-or-autonomy`; - `policy.visual_evidence`: `off`, `optional`, or `required`; - at least the `cli` host. +- an explicit `projections` array; `[]` is valid, and every selected projection + must be one of `codex`, `claude`, `cursor`, or `gemini` with its matching host + enabled. + +`hosts` controls runtime admission. `projections` controls only generated +repository files and never grants runtime authority. Projection order is +non-semantic: Boatstack sorts the IDs before computing the SHA-256 selection +fingerprint over `{"schema_version":1,"projections":[...]}`. The only accepted external-effect authority policy is `human-or-autonomy-plus-provider`. Provider authority is an independent @@ -98,6 +107,27 @@ refuses attachment. A host omitted from `hosts` cannot request managed transitions. If the configured default branch cannot be inspected, the high-risk derivation fails closed whenever that policy is active. +## Changing hosts or projections + +Host and projection selection changes use the governed configuration boundary: + +1. Write a candidate schema-4 configuration with the desired `hosts` and + `projections`. +2. Apply it through `configuration.mutate`. Boatstack installs that exact config + and its selected maintenance projections atomically. +3. Keep product work suspended while compiling and checking every Flow against + the new selection. +4. Run the normal installation update to admit the exact new control bundle. + Projection-only changes do not require program-change acceptance; + `installation.reconcile-update` is only for independent compiled-program + drift. +5. Resume the original Flow only after configuration, maintenance manifest, + Flow artifacts, projections, and control bundle all verify. + +Retirement removes only manifest- or ownership-bound files whose current bytes +still match their recorded hashes. Modified or unrelated files fail closed and +remain present; host directories are never removed. + ## Optional additive extensions Repository configuration may enable checksum-bound subprocess extensions, but @@ -148,7 +178,7 @@ document; an arbitrary fingerprint string is insufficient. To change configuration, write a candidate file elsewhere, then request `configuration.mutate`. The CLI derives `config_sha256` from the strict decoded -schema-3 value in canonical JSON form. Formatting, object-key order, and LF/CRLF +schema-4 value in canonical JSON form. Formatting, object-key order, and LF/CRLF checkout conversion therefore retain the same authority, while any controlling value change produces a new fingerprint. The kernel still copies the exact candidate bytes, installs state last, re-observes the tracked file, and accepts diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index 95ca48f..da26f0b 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -69,7 +69,7 @@ repository `node_modules/.bin` program automatically. The frontend accepts only literal data and calls to named exports from trusted Boatstack SDKs. It rejects local imports and other repository code without executing them. Boatstack then validates, canonicalizes, fingerprints, and -projects generated skills, retires obsolete skills, and publishes the committed +projects generated host-native files, retires obsolete projections, and publishes the committed `.flow.ir.json` artifact last as one serialized update. Runtime commands never execute `flow.ts`. The artifact filename comes from the declared program ID, not the source filename. diff --git a/install.ps1 b/install.ps1 index 5f49a96..07605e6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -116,11 +116,12 @@ try { if (-not $ConfigSource) { $ConfigSource = Join-Path $Temporary "project.json" $Config = [ordered]@{ - schema_version = 3 + schema_version = 4 identity = [ordered]@{ human = [ordered]@{ kind = "literal"; value = $Actor } } project = [ordered]@{ name = "repository"; default_branch = $DefaultBranch; commands = [ordered]@{} } policy = [ordered]@{ plan_approval = "human"; visual_evidence = "optional" } hosts = @("cli", "cursor", "codex", "claude", "gemini", "mcp") + projections = @("codex", "claude", "cursor", "gemini") } $ConfigText = $Config | ConvertTo-Json -Depth 4 -Compress [System.IO.File]::WriteAllText($ConfigSource, $ConfigText, [System.Text.UTF8Encoding]::new($false)) @@ -145,7 +146,7 @@ try { } Write-Host "Boatstack installed at $Runtime" if ($Mode -ne "hydrate") { - Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host skills" + Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host projections" } Write-Host "Run: $Launcher doctor --repo `"$Repository`" --format text" } finally { diff --git a/install.sh b/install.sh index 88897ba..7a4fbf1 100755 --- a/install.sh +++ b/install.sh @@ -143,7 +143,7 @@ if [[ "$mode" == install ]]; then config_source="$temporary/project.json" json_default_branch="${default_branch//\\/\\\\}" json_default_branch="${json_default_branch//\"/\\\"}" - printf '%s\n' "{\"schema_version\":3,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"$actor\"}},\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"]}" > "$config_source" + printf '%s\n' "{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"$actor\"}},\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"],\"projections\":[\"codex\",\"claude\",\"cursor\",\"gemini\"]}" > "$config_source" fi "$runtime" init --repo "$repository" --human "$actor" --param "config_path=$config_source" --format text elif [[ "$mode" == update ]]; then @@ -167,6 +167,6 @@ fi echo "Boatstack installed at $runtime" if [[ "$mode" != hydrate ]]; then - echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host skills" + echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host projections" fi echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/project.example.json b/project.example.json index 64cd9d1..d3812c7 100644 --- a/project.example.json +++ b/project.example.json @@ -1,5 +1,5 @@ { - "schema_version": 3, + "schema_version": 4, "identity": { "human": { "kind": "command", @@ -42,5 +42,11 @@ "gemini", "mcp", "sdk" + ], + "projections": [ + "codex", + "claude", + "cursor", + "gemini" ] } diff --git a/release-notes/2026-08-17-host-projection-selection.md b/release-notes/2026-08-17-host-projection-selection.md new file mode 100644 index 0000000..7e48605 --- /dev/null +++ b/release-notes/2026-08-17-host-projection-selection.md @@ -0,0 +1,9 @@ +### Breaking host and projection selection + +Boatstack project configuration schema 4 now requires an explicit `projections` +selection separate from runtime `hosts`. Flow and maintenance generation support +Codex, Claude, Cursor, and Gemini independently, bind the canonical selection and +generated bytes into versioned artifacts, ownership, manifests, and control +bundles, and retire only exact unmodified managed outputs. Existing repositories +must add `projections`, govern the configuration change, recompile every Flow, +and run an installation update before resuming product work. From b22f5641408eefe2e25a91ca23db197fef516e92 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 07:26:57 +0100 Subject: [PATCH 2/6] test: make projection fixture commits deterministic --- boatstack/cmd/boatstack-helper/flow_runtime_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index c88c3a6..df25a8f 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -708,7 +708,7 @@ func writeMaintenanceProjectionFixture(t *testing.T, repository string) { arguments := append([]string{"add", "--"}, paths...) runFlowGit(t, repository, arguments...) if staged := runFlowGitOutput(t, repository, "diff", "--cached", "--name-only", "--"); staged != "" { - commitArguments := append([]string{"commit", "-q", "-m", "fixture maintenance projections", "--only", "--"}, paths...) + commitArguments := append([]string{"-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture maintenance projections", "--only", "--"}, paths...) runFlowGit(t, repository, commitArguments...) } } From d6f5f3570f5d9185216e043fcac04c33cf34b1c5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 07:38:15 +0100 Subject: [PATCH 3/6] fix: align control bundle size admission --- boatstack/internal/runtime/control_bundle.go | 16 ++++++- .../internal/runtime/control_bundle_test.go | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go index aa9ab4e..a6beeca 100644 --- a/boatstack/internal/runtime/control_bundle.go +++ b/boatstack/internal/runtime/control_bundle.go @@ -17,7 +17,10 @@ import ( "strings" ) -const ControlBundleSchemaVersion = 1 +const ( + ControlBundleSchemaVersion = 1 + maxControlBundleFileSize = int64(64 << 20) +) // ControlBundleFile binds one repository-relative control file to exact bytes. type ControlBundleFile struct { @@ -68,6 +71,9 @@ func NewControlBundleSnapshotWithMemberSets(files map[string][]byte, absent []st if !safeProjectionRelative(path) { return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) } + if int64(len(raw)) > maxControlBundleFileSize { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %s exceeds the maximum control-bundle file size", path) + } digest := sha256.Sum256(raw) bindings = append(bindings, ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])}) } @@ -100,6 +106,9 @@ func NewControlBundleSnapshotWithMemberSets(files map[string][]byte, absent []st // ReplaceControlBundleFile derives a target snapshot without trusting a // caller-supplied target fingerprint. func ReplaceControlBundleFile(snapshot ControlBundleSnapshot, path string, raw []byte) (ControlBundleSnapshot, error) { + if int64(len(raw)) > maxControlBundleFileSize { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %s exceeds the maximum control-bundle file size", path) + } digest := sha256.Sum256(raw) return replaceControlBundleBinding(snapshot, ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])}) } @@ -541,7 +550,7 @@ func VerifyControlBundleRevision(ctx context.Context, repository, revision strin return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks regular file %s", revision, file.Path) } size, parseErr := strconv.ParseInt(fields[2], 10, 64) - if parseErr != nil || size < 0 || size > 64<<20 { + if parseErr != nil || size < 0 || size > maxControlBundleFileSize { return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has invalid size for %s", revision, file.Path) } raw := make([]byte, size) @@ -653,6 +662,9 @@ func readBundleFile(repository, relative string) ([]byte, error) { if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return nil, fmt.Errorf("control file is not a regular file") } + if info.Size() > maxControlBundleFileSize { + return nil, fmt.Errorf("control file exceeds the maximum control-bundle file size") + } resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) if err != nil || (resolvedParent != repository && !strings.HasPrefix(resolvedParent, repository+string(filepath.Separator))) { return nil, fmt.Errorf("control file escapes repository") diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go index df11a51..8098d40 100644 --- a/boatstack/internal/runtime/control_bundle_test.go +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -2,6 +2,8 @@ package runtime import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" "os/exec" @@ -85,6 +87,48 @@ func TestControlBundleRejectsPathsThatCanSplitGitBatchRequests(t *testing.T) { } } +func TestControlBundleSizeLimitIsConsistentAcrossAdmissionAndVerification(t *testing.T) { + oversized := make([]byte, maxControlBundleFileSize+1) + path := ".boatstack/oversized.lock" + if _, err := NewControlBundleSnapshot(map[string][]byte{path: oversized}); err == nil || !strings.Contains(err.Error(), "maximum control-bundle file size") { + t.Fatalf("snapshot admission accepted oversized member: %v", err) + } + base, err := NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("project\n")}) + if err != nil { + t.Fatal(err) + } + if _, err := ReplaceControlBundleFile(base, path, oversized); err == nil || !strings.Contains(err.Error(), "maximum control-bundle file size") { + t.Fatalf("snapshot replacement accepted oversized member: %v", err) + } + + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + if err := os.MkdirAll(filepath.Join(repository, ".boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(path)), oversized, 0o644); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(oversized) + files := []ControlBundleFile{{Path: path, SHA256: hex.EncodeToString(digest[:])}} + fingerprint, err := controlBundleSnapshotDigest(files, nil) + if err != nil { + t.Fatal(err) + } + snapshot := ControlBundleSnapshot{Fingerprint: fingerprint, Files: files} + runBundleGit(t, repository, "add", path) + runBundleGit(t, repository, "commit", "-q", "-m", "oversized") + revision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + if err := VerifyControlBundleRoot(repository, snapshot); err == nil || !strings.Contains(err.Error(), "maximum control-bundle file size") { + t.Fatalf("root verification accepted oversized member: %v", err) + } + if err := VerifyControlBundleRevision(context.Background(), repository, revision, snapshot); err == nil || !strings.Contains(err.Error(), "invalid size") { + t.Fatalf("revision verification accepted oversized member: %v", err) + } +} + func TestReplaceControlBundleFileAbsentBindsRetirement(t *testing.T) { snapshot, err := NewControlBundleSnapshot(map[string][]byte{ ".boatstack/project.json": []byte("project"), From a4b92a4f1dcdbd34fc5e9885934981c15b5f841b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 08:04:08 +0100 Subject: [PATCH 4/6] Serialize maintenance projection retirement --- boatstack/cmd/boatstack-helper/main.go | 14 +++++ .../softwaredelivery/effects/locker.go | 27 +++++++- .../softwaredelivery/effects/locker_test.go | 63 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index ee3d544..734cf4d 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -543,12 +543,26 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De } func acquireFlowExecutionLease(request surfaces.Request) (*boatstackruntime.FlowProjectionLease, error) { + if transitionOwnsMaintenanceProjections(request.TransitionID) { + // The Kernel's configuration/installation resource lock holds this same + // lease across final preparation, effect execution, and verification. + return &boatstackruntime.FlowProjectionLease{}, nil + } if request.ProgramID == "" && request.ControlBundle == nil { return &boatstackruntime.FlowProjectionLease{}, nil } return boatstackruntime.AcquireFlowProjectionLease(request.Repository) } +func transitionOwnsMaintenanceProjections(id catalog.TransitionID) bool { + switch id { + case "configuration.initialize", "configuration.mutate", "installation.initialize", "installation.update", "installation.reconcile-update": + return true + default: + return false + } +} + func refreshFlowInvocation(ctx context.Context, operation surfaces.Operation, prior surfaces.Request, options commandOptions) (surfaces.Request, commandOptions, error) { prescription := prior.Prescription repositoryTransition, err := repositoryFlowDeclaresTransition(options.repository, options.programID, options.transitionID) diff --git a/boatstack/internal/softwaredelivery/effects/locker.go b/boatstack/internal/softwaredelivery/effects/locker.go index bf5269a..99140c1 100644 --- a/boatstack/internal/softwaredelivery/effects/locker.go +++ b/boatstack/internal/softwaredelivery/effects/locker.go @@ -10,6 +10,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" ) @@ -30,10 +31,17 @@ type heldLock struct { file *os.File } -type heldLocks struct{ values []heldLock } +type heldLocks struct { + values []heldLock + projectionLease *boatstackruntime.FlowProjectionLease +} func (l *heldLocks) Release() error { var first error + if l.projectionLease != nil { + l.projectionLease.Release() + l.projectionLease = nil + } for index := len(l.values) - 1; index >= 0; index-- { value := l.values[index] if err := unlockFile(value.file); err != nil && first == nil { @@ -122,5 +130,22 @@ func (l Locker) Acquire(ctx context.Context, invocation model.InvocationContext, } held.values = append(held.values, heldLock{path: path, file: file}) } + if maintenanceProjectionResource(resources) { + lease, leaseErr := boatstackruntime.AcquireFlowProjectionLease(layout.RepositoryRoot) + if leaseErr != nil { + _ = held.Release() + return nil, fmt.Errorf("acquire maintenance projection lease: %w", leaseErr) + } + held.projectionLease = lease + } return held, nil } + +func maintenanceProjectionResource(resources []string) bool { + for _, resource := range resources { + if resource == "configuration" || resource == "installation" { + return true + } + } + return false +} diff --git a/boatstack/internal/softwaredelivery/effects/locker_test.go b/boatstack/internal/softwaredelivery/effects/locker_test.go index b98e7af..7adea35 100644 --- a/boatstack/internal/softwaredelivery/effects/locker_test.go +++ b/boatstack/internal/softwaredelivery/effects/locker_test.go @@ -4,9 +4,12 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" + "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" ) @@ -85,3 +88,63 @@ func TestInstallationLocksAreRepositoryScoped(t *testing.T) { t.Fatal(err) } } + +func TestConfigurationLockSerializesSharedProjectionPublicationAcrossPreparation(t *testing.T) { + // control-law: maintenance-reference-observation-and-retirement-share-the-flow-projection-lease + repository, err := filepath.EvalSymlinks(recoveryRepository(t)) + if err != nil { + t.Fatal(err) + } + resolver, err := plant.NewResolver(t.TempDir()) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "configuration-prepared") + if err != nil { + t.Fatal(err) + } + locker, err := NewLocker(resolver) + if err != nil { + t.Fatal(err) + } + configuration, err := locker.Acquire(context.Background(), invocation, []string{"configuration"}) + if err != nil { + t.Fatal(err) + } + + sharedRelative, sharedContent, ok := hostprojection.SharedCheckoutPath(hostprojection.Gemini) + if !ok { + t.Fatal("Gemini shared checkout path is unavailable") + } + source := ".boatstack/flows/concurrent.flow.ts" + artifact := ".boatstack/flows/concurrent.flow.ir.json" + artifactRaw := []byte("concurrent artifact") + prior, err := boatstackruntime.LoadFlowProjectionOwnership(repository, source) + if err != nil { + t.Fatal(err) + } + next := boatstackruntime.NewFlowProjectionOwnership(source, artifact, artifactRaw, strings.Repeat("a", 64), map[string][]byte{sharedRelative: sharedContent}) + writes := []boatstackruntime.ProjectionWrite{ + {Path: filepath.Join(repository, filepath.FromSlash(sharedRelative)), Content: sharedContent, Mode: 0o644}, + {Path: filepath.Join(repository, filepath.FromSlash(artifact)), Content: artifactRaw, Mode: 0o644, PublishLast: true}, + } + if err := boatstackruntime.ApplyOwnedFlowProjection(repository, writes, nil, nil, prior, next); err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_BUSY") { + t.Fatalf("Flow publication crossed prepared configuration effect: %v", err) + } + if current, err := boatstackruntime.LoadFlowProjectionOwnership(repository, source); err != nil || current.Exists() { + t.Fatalf("blocked Flow publication installed ownership: exists=%t err=%v", current.Exists(), err) + } + if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(sharedRelative))); !os.IsNotExist(err) { + t.Fatalf("blocked Flow publication installed shared metadata: %v", err) + } + + if err := configuration.Release(); err != nil { + t.Fatal(err) + } + if err := boatstackruntime.ApplyOwnedFlowProjection(repository, writes, nil, nil, prior, next); err != nil { + t.Fatalf("Flow publication remained blocked after configuration settlement: %v", err) + } + if actual, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(sharedRelative))); err != nil || string(actual) != string(sharedContent) { + t.Fatalf("published shared metadata = %q, %v", actual, err) + } +} From 7bcd6fd3b54868cde7cfc2c1dc6dc83b53064f18 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 08:13:54 +0100 Subject: [PATCH 5/6] Encode semantic IDs in projection slugs --- .../flow/softwaredelivery/projections.go | 11 +++--- .../flow/softwaredelivery/projections_test.go | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/boatstack/flow/softwaredelivery/projections.go b/boatstack/flow/softwaredelivery/projections.go index 3e1a998..f875e2d 100644 --- a/boatstack/flow/softwaredelivery/projections.go +++ b/boatstack/flow/softwaredelivery/projections.go @@ -52,12 +52,15 @@ func GenerateProjections(compiled controlprogram.Compiled, projections []hostpro } func flowSkillSlug(programID, entryID string) string { + return projectionSlugComponent(programID, false) + "-" + projectionSlugComponent(entryID, true) +} + +func projectionSlugComponent(value string, encodeHyphen bool) string { const encodedPrefix = "x0" - encodedEntry := entryID - if strings.Contains(entryID, "-") || strings.HasPrefix(entryID, encodedPrefix) { - encodedEntry = encodedPrefix + hex.EncodeToString([]byte(entryID)) + if strings.ContainsAny(value, "._") || strings.HasPrefix(value, encodedPrefix) || (encodeHyphen && strings.Contains(value, "-")) { + return encodedPrefix + hex.EncodeToString([]byte(value)) } - return programID + "-" + encodedEntry + return value } func renderProjection(compiled controlprogram.Compiled, entry controlprogram.Entry, slug, host string) []byte { diff --git a/boatstack/flow/softwaredelivery/projections_test.go b/boatstack/flow/softwaredelivery/projections_test.go index 1fbf014..b23c9b2 100644 --- a/boatstack/flow/softwaredelivery/projections_test.go +++ b/boatstack/flow/softwaredelivery/projections_test.go @@ -433,3 +433,38 @@ func TestGeneratedSkillIdentityIsInjectiveAcrossProgramEntryPairs(t *testing.T) } } } + +func TestGeneratedProjectionSlugsEncodeEveryValidSemanticSeparator(t *testing.T) { + // control-law: every-control-program-semantic-id-has-an-injective-valid-host-projection + type identity struct{ program, entry string } + identities := []identity{ + {program: "incident.response", entry: "respond"}, + {program: "x0696e636964656e742e726573706f6e7365", entry: "respond"}, + {program: "incident_response", entry: "respond.now"}, + {program: "incident-response", entry: "respond-now"}, + {program: "incident", entry: "respond_now"}, + } + seen := map[string]identity{} + for _, value := range identities { + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: value.program}, + Entries: []controlprogram.Entry{{ID: value.entry, Target: "done"}}, + }} + files, err := softwareflow.GenerateProjections(compiled, hostprojection.CanonicalIDs()) + if err != nil { + t.Fatalf("GenerateProjections(%s, %s): %v", value.program, value.entry, err) + } + for path := range files { + if !hostprojection.ValidFlowPath(path) { + t.Fatalf("semantic identity %v generated invalid path %q", value, path) + } + if hostprojection.IsSharedCheckoutPath(path) { + continue + } + if prior, collision := seen[path]; collision { + t.Fatalf("semantic identities %v and %v collide at %s", prior, value, path) + } + seen[path] = value + } + } +} From b0167b9d2325b309a0ade4e26fb09f1910763dfb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 08:34:17 +0100 Subject: [PATCH 6/6] fix projection ownership admission boundaries --- .../cmd/boatstack-helper/control_bundle.go | 30 ++++++ .../cmd/boatstack-helper/flow_runtime_test.go | 32 +++++++ .../effects/host_projections.go | 14 ++- .../effects/host_projections_test.go | 94 +++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go index 794e136..d6b17ef 100644 --- a/boatstack/cmd/boatstack-helper/control_bundle.go +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -257,6 +257,33 @@ func replaceHostProjectionBundle(repository string, snapshot boatstackruntime.Co return projected, nil } +func validateRepositoryFlowArtifactsForProjections(ctx context.Context, repository string, projections []hostprojection.ID) error { + artifacts, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ir.json")) + if err != nil { + return err + } + sort.Strings(artifacts) + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return err + } + for _, artifactPath := range artifacts { + raw, readErr := os.ReadFile(artifactPath) + if readErr != nil { + return readErr + } + artifact, loadErr := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if loadErr != nil { + return loadErr + } + if _, checkErr := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections); checkErr != nil { + relative, _ := filepath.Rel(repository, artifactPath) + return fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: Flow artifact %s does not match candidate project configuration: %w", filepath.ToSlash(relative), checkErr) + } + } + return nil +} + func controlBundleRequired(id catalog.TransitionID) bool { switch id { case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile", @@ -321,6 +348,9 @@ func bindControlBundle(ctx context.Context, repository string, transitionID cata if projectionErr != nil { return nil, "", projectionErr } + if projectionErr = validateRepositoryFlowArtifactsForProjections(ctx, repository, projections); projectionErr != nil { + return nil, "", projectionErr + } hostFiles, manifestRaw, projectionErr := effects.ProjectedHostProjectionFiles(projections) if projectionErr != nil { return nil, "", projectionErr diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index df25a8f..3578b86 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1214,6 +1214,38 @@ func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { } } +func TestInstallationInitializeRejectsArtifactsFromDifferentCandidateProjectionSelection(t *testing.T) { + // control-law: initialization-target-artifacts-match-the-candidate-project-selection + repository := flowRepository(t) + candidateRaw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}`) + candidatePath := filepath.Join(t.TempDir(), "project.json") + if err := os.WriteFile(candidatePath, candidateRaw, 0o600); err != nil { + t.Fatal(err) + } + _, candidateFingerprint, err := protocol.ProjectConfigFingerprint(candidateRaw) + if err != nil { + t.Fatal(err) + } + projectBefore, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if err != nil { + t.Fatal(err) + } + contract, _, err := bindControlBundle(context.Background(), repository, "installation.initialize", protocol.Parameters{ + {Name: "config_path", Value: candidatePath}, + {Name: "config_sha256", Value: candidateFingerprint}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_SELECTION_STALE") { + t.Fatalf("mismatched initialization target = contract %#v, err %v", contract, err) + } + projectAfter, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if readErr != nil || string(projectAfter) != string(projectBefore) { + t.Fatalf("refused initialization changed project config: %q, %v", projectAfter, readErr) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "host-projections.json")); !os.IsNotExist(statErr) { + t.Fatalf("refused initialization installed maintenance manifest: %v", statErr) + } +} + func TestWorkspaceCutRejectsUncommittedRuntimePinBeforeEffect(t *testing.T) { // control-law: a runtime pin cannot outrun the committed Flow projection repository := flowRepository(t) diff --git a/boatstack/internal/softwaredelivery/effects/host_projections.go b/boatstack/internal/softwaredelivery/effects/host_projections.go index 549c6d7..1c7b6b9 100644 --- a/boatstack/internal/softwaredelivery/effects/host_projections.go +++ b/boatstack/internal/softwaredelivery/effects/host_projections.go @@ -220,8 +220,18 @@ func prepareHostProjectionMutations(repository string, projections []hostproject if readErr != nil { return nil, readErr } - if exists && !manifestExists && !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { - return nil, fmt.Errorf("unmanaged file collides with Boatstack host projection %s", relative) + _, maintenanceOwned := prior.Files[relative] + if exists && !maintenanceOwned { + flowOwned := false + if hostprojection.IsSharedCheckoutPath(relative) && strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { + flowOwned, err = boatstackruntime.SharedFlowProjectionReferenced(repository, relative, sha256Bytes(desired[relative])) + if err != nil { + return nil, err + } + } + if !flowOwned { + return nil, fmt.Errorf("unmanaged file collides with Boatstack host projection %s", relative) + } } if !exists || !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { mutation, mutationErr := mutationFor(absolute, desired[relative], 0o644, false, false) diff --git a/boatstack/internal/softwaredelivery/effects/host_projections_test.go b/boatstack/internal/softwaredelivery/effects/host_projections_test.go index 4e0979c..196c98c 100644 --- a/boatstack/internal/softwaredelivery/effects/host_projections_test.go +++ b/boatstack/internal/softwaredelivery/effects/host_projections_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" ) @@ -179,6 +180,99 @@ func TestHostProjectionProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { } } +func TestHostProjectionProjectionRefusesUnlistedSharedMetadataWithExistingManifest(t *testing.T) { + // control-law: maintenance-ownership-is-proved-per-path-even-for-equal-shared-bytes + for _, projection := range []hostprojection.ID{hostprojection.Cursor, hostprojection.Gemini} { + t.Run(string(projection), func(t *testing.T) { + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o700); err != nil { + t.Fatal(err) + } + initial, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex}) + if err != nil { + t.Fatal(err) + } + applyMutationsForTest(t, initial) + manifestPath := filepath.Join(repository, ".boatstack", "host-projections.json") + manifestBefore, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + sharedRelative, sharedContent, ok := hostprojection.SharedCheckoutPath(projection) + if !ok { + t.Fatalf("%s has no shared checkout metadata", projection) + } + sharedPath := filepath.Join(repository, filepath.FromSlash(sharedRelative)) + if err := os.MkdirAll(filepath.Dir(sharedPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sharedPath, sharedContent, 0o644); err != nil { + t.Fatal(err) + } + if _, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex, projection}); err == nil || !strings.Contains(err.Error(), "unmanaged file collides") { + t.Fatalf("equal-byte unowned collision was not rejected: %v", err) + } + if actual, err := os.ReadFile(sharedPath); err != nil || string(actual) != string(sharedContent) { + t.Fatalf("unowned shared metadata changed: %q, %v", actual, err) + } + if manifestAfter, err := os.ReadFile(manifestPath); err != nil || string(manifestAfter) != string(manifestBefore) { + t.Fatalf("manifest changed after refusal: %q, %v", manifestAfter, err) + } + }) + } +} + +func TestHostProjectionProjectionMayAddMaintenanceOwnerForExactFlowMetadata(t *testing.T) { + // control-law: an exact Flow owner permits shared maintenance reference-counting + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o700); err != nil { + t.Fatal(err) + } + initial, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex}) + if err != nil { + t.Fatal(err) + } + applyMutationsForTest(t, initial) + sharedRelative, sharedContent, _ := hostprojection.SharedCheckoutPath(hostprojection.Cursor) + source := ".boatstack/flows/shared.flow.ts" + artifact := ".boatstack/flows/shared.flow.ir.json" + artifactRaw := []byte("artifact") + prior, err := boatstackruntime.LoadFlowProjectionOwnership(repository, source) + if err != nil { + t.Fatal(err) + } + next := boatstackruntime.NewFlowProjectionOwnership(source, artifact, artifactRaw, strings.Repeat("a", 64), map[string][]byte{sharedRelative: sharedContent}) + writes := []boatstackruntime.ProjectionWrite{ + {Path: filepath.Join(repository, filepath.FromSlash(sharedRelative)), Content: sharedContent, Mode: 0o644}, + {Path: filepath.Join(repository, filepath.FromSlash(artifact)), Content: artifactRaw, Mode: 0o644, PublishLast: true}, + } + if err := boatstackruntime.ApplyOwnedFlowProjection(repository, writes, nil, nil, prior, next); err != nil { + t.Fatal(err) + } + mutations, err := prepareHostProjectionMutations(repository, []hostprojection.ID{hostprojection.Codex, hostprojection.Cursor}) + if err != nil { + t.Fatalf("exact Flow-owned shared metadata was rejected: %v", err) + } + applyMutationsForTest(t, mutations) + manifestRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "host-projections.json")) + if err != nil { + t.Fatal(err) + } + var manifest hostProjectionManifest + if err := decodeHostProjectionManifest(manifestRaw, &manifest); err != nil { + t.Fatal(err) + } + if manifest.Files[sharedRelative] != sha256Bytes(sharedContent) { + t.Fatalf("maintenance manifest did not bind shared metadata: %#v", manifest.Files) + } +} + func TestHostProjectionManifestRejectsUnknownFieldsAndSelectionDrift(t *testing.T) { files, raw, err := ProjectedHostProjectionFiles([]hostprojection.ID{hostprojection.Codex}) if err != nil || len(files) == 0 {