diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 41fa04f..2886898 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:ed474c953d73b3ec1afab5fd9a576cfe593fc979d1010389574acbe097d3ec01" - digest: "sha256:43889e1cb7cc1d2911819e402b9382e32e0d87ceae4b301f7ecb8e97b0501861" + source_tree_digest: "sha256:0b0bf68b9ab468748b19776ce40aaf9d1cbc8b3ebd764fceeec9f0135cc1457b" + digest: "sha256:42f16cda41db4b4a14b6b6f8bfb39286416c4a0b1cc1bbf4f2e780e10e9fd5f7" projection: - input_digest: "sha256:0ede97348a7da35fcd2d86fe3c27782ffb57935712c8cacb9c058440d673c830" - output_digest: "sha256:18fbc4bd685017f12bf53578fb43248f57738685b95a65285101434896aba652" + input_digest: "sha256:fd431f29d3e475e8c26065692f17245c3dbace5dc8c1c98839eb5ffdac2f3499" + output_digest: "sha256:9026d8394956deebe4214d6cf2c1c8e2cb91d99216f59d9087364105c306723a" files: - path: ".gds/compiled-policy.json" digest: "sha256:b5517ed46f67866220c2b18dbfbda4a40d99f00327611e56742a118d0ac59d0b" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:b5174442c13c9f3b9cea05da57296239a72b3819d4064a46af14cec97fcacb79" + digest: "sha256:7e03a6468d3d0322208874a3ed8433c41c9d2bf6755191b9e63f818f6204909d" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 23eac94..75f98f5 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:ed474c953d73b3ec1afab5fd9a576cfe593fc979d1010389574acbe097d3ec01 -# input-digest: sha256:0ede97348a7da35fcd2d86fe3c27782ffb57935712c8cacb9c058440d673c830 +# source-tree-digest: sha256:0b0bf68b9ab468748b19776ce40aaf9d1cbc8b3ebd764fceeec9f0135cc1457b +# input-digest: sha256:fd431f29d3e475e8c26065692f17245c3dbace5dc8c1c98839eb5ffdac2f3499 # output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4 # edit-source: # - .gds/repository.yaml diff --git a/core/app/services.go b/core/app/services.go index 58d8b7d..e5025bf 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -781,7 +781,8 @@ func (services *Services) ValidateSchemas( return envelopeForError("gds validate schemas", candidate, err) } } - findings := services.Schemas.ValidateCanonical(root, fixturePath) + engineRoot := projections.ResolveDevelopmentSourceLayout(root).EngineRoot + findings := services.Schemas.ValidateCanonical(root, engineRoot, fixturePath) class := classifyFindings(findings) return domain.NewEnvelope("gds validate schemas", class, ValidationData{ Target: root, SchemaCount: len(services.Schemas.Names()), @@ -1006,7 +1007,8 @@ func (services *Services) PackagePlugin( if err != nil { return envelopeForError("gds skill package", path, err) } - candidate, findings := skills.BuildPackage(info.WorktreeRoot, plugin, services.Schemas) + staticRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot + candidate, findings := skills.BuildPackage(info.WorktreeRoot, staticRoot, plugin, services.Schemas) return domain.NewEnvelope( "gds skill package", classifyFindings(findings), candidate, findings..., ) @@ -1021,9 +1023,10 @@ func (services *Services) ValidatePlugins(ctx context.Context, path string) doma findings := append([]domain.Finding{}, catalog.Findings...) data := PluginValidationData{} if len(catalog.Findings) == 0 { + staticRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot for _, plugin := range catalog.Registry.Plugins { candidate, pluginFindings := skills.BuildPackage( - info.WorktreeRoot, plugin.ID, services.Schemas, + info.WorktreeRoot, staticRoot, plugin.ID, services.Schemas, ) data.Packages = append(data.Packages, candidate) findings = append(findings, pluginFindings...) @@ -1045,9 +1048,10 @@ func (services *Services) ValidateHarness( } var report any var findings []domain.Finding + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot switch harnessID { case "all": - report, findings = harness.ValidateAll(info.WorktreeRoot, services.Schemas) + report, findings = harness.ValidateAll(engineRoot, services.Schemas) case "selected": selected, selectionFindings := services.estateSelectedHarnesses(info.WorktreeRoot) if len(selectionFindings) != 0 { @@ -1055,9 +1059,9 @@ func (services *Services) ValidateHarness( "gds validate harnesses", classifyFindings(selectionFindings), nil, selectionFindings..., ) } - report, findings = harness.ValidateSelected(info.WorktreeRoot, selected, services.Schemas) + report, findings = harness.ValidateSelected(engineRoot, selected, services.Schemas) default: - report, findings = harness.Validate(info.WorktreeRoot, harnessID, services.Schemas) + report, findings = harness.Validate(engineRoot, harnessID, services.Schemas) } return domain.NewEnvelope( "gds validate harnesses", classifyFindings(findings), report, findings..., @@ -1118,10 +1122,11 @@ func (services *Services) ValidateHarnessStatic( } var report any var findings []domain.Finding + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot if harnessID == "all" { - report, findings = harness.ValidateStaticAll(info.WorktreeRoot, services.Schemas) + report, findings = harness.ValidateStaticAll(engineRoot, services.Schemas) } else { - report, findings = harness.ValidateStatic(info.WorktreeRoot, harnessID, services.Schemas) + report, findings = harness.ValidateStatic(engineRoot, harnessID, services.Schemas) } return domain.NewEnvelope( "gds validate harnesses", classifyFindings(findings), report, findings..., diff --git a/core/harness/codex.go b/core/harness/codex.go index d8e5a30..0e5d23d 100644 --- a/core/harness/codex.go +++ b/core/harness/codex.go @@ -225,7 +225,7 @@ func validateMarketplaceAndPackages( )) continue } - candidate, packageFindings := skills.BuildPackage(root, plugin, schemas) + candidate, packageFindings := skills.BuildPackage(root, root, plugin, schemas) report.Plugins = append(report.Plugins, candidate) findings = append(findings, packageFindings...) } diff --git a/core/releasebuilder/builder.go b/core/releasebuilder/builder.go index 3b0a03a..fd5ed05 100644 --- a/core/releasebuilder/builder.go +++ b/core/releasebuilder/builder.go @@ -498,7 +498,7 @@ func releaseAdditionalFiles( }) } for _, plugin := range []string{"gds-core", "gds-estate-admin", "gds-module"} { - candidate, findings := skills.BuildPackage(root, plugin, schemas) + candidate, findings := skills.BuildPackage(root, root, plugin, schemas) if len(findings) != 0 { return nil, findingError("build plugin "+plugin, findings) } diff --git a/core/skills/package.go b/core/skills/package.go index cf30c60..d432056 100644 --- a/core/skills/package.go +++ b/core/skills/package.go @@ -73,7 +73,11 @@ type hookHandler struct { StatusMessage string `json:"statusMessage,omitempty"` } -func BuildPackage(root, pluginID string, schemas *validation.Set) (PackageCandidate, []domain.Finding) { +// BuildPackage packages one plugin for the authority rooted at root. The +// registry and every skill source belong to the authority; the plugin static +// files (hooks, manifest chrome) ship with the engine distribution and are +// read from staticRoot, which equals root on the engine repository itself. +func BuildPackage(root, staticRoot, pluginID string, schemas *validation.Set) (PackageCandidate, []domain.Finding) { outcome := Validate(root, schemas) if len(outcome.Findings) != 0 { return PackageCandidate{Plugin: pluginID}, outcome.Findings @@ -89,7 +93,7 @@ func BuildPackage(root, pluginID string, schemas *validation.Set) (PackageCandid if len(findings) != 0 { return PackageCandidate{Plugin: pluginID}, findings } - contents, staticFindings := pluginStaticFiles(root, pluginID) + contents, staticFindings := pluginStaticFiles(staticRoot, pluginID) findings = append(findings, staticFindings...) for _, definition := range selected { for _, relative := range []string{"SKILL.md", filepath.Join("agents", "openai.yaml")} { diff --git a/core/skills/package_test.go b/core/skills/package_test.go index 836cc0d..ad36e60 100644 --- a/core/skills/package_test.go +++ b/core/skills/package_test.go @@ -19,11 +19,11 @@ func TestBuildPackagesAreDeterministicAndStandalone(t *testing.T) { t.Fatal(err) } for _, plugin := range []string{"gds-core", "gds-estate-admin", "gds-module"} { - first, findings := BuildPackage(root, plugin, schemas) + first, findings := BuildPackage(root, root, plugin, schemas) if len(findings) != 0 { t.Fatalf("%s findings: %+v", plugin, findings) } - second, findings := BuildPackage(root, plugin, schemas) + second, findings := BuildPackage(root, root, plugin, schemas) if len(findings) != 0 { t.Fatalf("%s second findings: %+v", plugin, findings) } diff --git a/core/validation/canonical_layout_test.go b/core/validation/canonical_layout_test.go new file mode 100644 index 0000000..405cddb --- /dev/null +++ b/core/validation/canonical_layout_test.go @@ -0,0 +1,55 @@ +package validation + +import ( + "os" + "path/filepath" + "testing" +) + +// An external estate owns its anchor and skills registry but pins the engine +// as a module; the engine-distribution inputs must be validated from the +// engine root, not the authority root. Measured on the live estate: reading +// them from the authority root produced five unconditional read failures +// (issue #65). +func TestValidateCanonicalReadsEngineInputsFromTheEngineRoot(t *testing.T) { + t.Parallel() + set, err := NewSchemaSet() + if err != nil { + t.Fatalf("NewSchemaSet() error = %v", err) + } + engineRoot := repositoryRoot(t) + authorityRoot := t.TempDir() + for _, relative := range []string{ + filepath.Join(".gds", "repository.yaml"), + filepath.Join("skills", "registry.yaml"), + } { + raw, err := os.ReadFile(filepath.Join(engineRoot, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + target := filepath.Join(authorityRoot, relative) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, raw, 0o644); err != nil { + t.Fatal(err) + } + } + + if findings := set.ValidateCanonical(authorityRoot, engineRoot, ""); len(findings) != 0 { + t.Fatalf("external layout findings = %#v", findings) + } + + // The control pins the defect this split fixes: collapsing both roots + // onto the authority loses exactly the five engine-distribution inputs. + missing := map[string]bool{} + for _, finding := range set.ValidateCanonical(authorityRoot, authorityRoot, "") { + if finding.Code != "GDS_INPUT_READ_FAILED" { + t.Fatalf("unexpected finding on collapsed roots: %#v", finding) + } + missing[finding.Message] = true + } + if len(missing) != 5 { + t.Fatalf("collapsed roots lost %d inputs, want the 5 engine inputs: %v", len(missing), missing) + } +} diff --git a/core/validation/schema.go b/core/validation/schema.go index 414bb45..f0e3b62 100644 --- a/core/validation/schema.go +++ b/core/validation/schema.go @@ -173,24 +173,31 @@ func (set *Set) ValidateFile(schemaName, path string) []domain.Finding { return set.Validate(schemaName, value, path) } -func (set *Set) ValidateCanonical(root string, fixtureIndex string) []domain.Finding { +// ValidateCanonical validates the canonical inputs of an authority rooted at +// root. Engine-distribution inputs (the migration registry, the harness +// registry and bridge, harness profiles, bundle trust, the source register) +// are read from engineRoot: on the engine repository the two roots coincide, +// while an external estate pins the engine as a module and owns only its +// anchor, skills and exceptions. Reading engine inputs from the estate root +// produced five unconditional read failures on every external authority. +func (set *Set) ValidateCanonical(root string, engineRoot string, fixtureIndex string) []domain.Finding { findings := []domain.Finding{} findings = append(findings, set.ValidateFile( "repository", filepath.Join(root, ".gds", "repository.yaml"), )...) findings = append(findings, set.ValidateFile( - "migration-registry", filepath.Join(root, "schemas", "migrations", "registry.yaml"), + "migration-registry", filepath.Join(engineRoot, "schemas", "migrations", "registry.yaml"), )...) findings = append(findings, set.ValidateFile( "skill-registry", filepath.Join(root, "skills", "registry.yaml"), )...) findings = append(findings, set.ValidateFile( - "harness-registry", filepath.Join(root, "harnesses", "capability-registry.yaml"), + "harness-registry", filepath.Join(engineRoot, "harnesses", "capability-registry.yaml"), )...) findings = append(findings, set.ValidateFile( - "module-harness-bridge", filepath.Join(root, "harnesses", "module-bridge.yaml"), + "module-harness-bridge", filepath.Join(engineRoot, "harnesses", "module-bridge.yaml"), )...) - profilePaths, err := filepath.Glob(filepath.Join(root, "harnesses", "*", "profile.yaml")) + profilePaths, err := filepath.Glob(filepath.Join(engineRoot, "harnesses", "*", "profile.yaml")) if err != nil { findings = append(findings, domain.Finding{ Code: "GDS_HARNESS_PROFILE_DISCOVERY_FAILED", Severity: domain.SeverityHigh, @@ -204,10 +211,10 @@ func (set *Set) ValidateCanonical(root string, fixtureIndex string) []domain.Fin } } findings = append(findings, set.ValidateFile( - "bundle-trust", filepath.Join(root, "requirements", "bundle-trust.yaml"), + "bundle-trust", filepath.Join(engineRoot, "requirements", "bundle-trust.yaml"), )...) findings = append(findings, set.ValidateFile( - "source-register", filepath.Join(root, "docs", "source-register", "sources.yaml"), + "source-register", filepath.Join(engineRoot, "docs", "source-register", "sources.yaml"), )...) exceptionPaths, err := filepath.Glob(filepath.Join(root, "estate", "exceptions", "*.yaml")) if err != nil { diff --git a/core/validation/schema_test.go b/core/validation/schema_test.go index 6cd1b1b..74f47a3 100644 --- a/core/validation/schema_test.go +++ b/core/validation/schema_test.go @@ -54,6 +54,7 @@ func TestCanonicalSchemasAndFixturesPass(t *testing.T) { } root := repositoryRoot(t) findings := set.ValidateCanonical( + root, root, filepath.Join(root, "tests", "fixtures", "schemas", "v1", "cases.json"), )