diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 9d6b1a1..4205530 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:c8582e4ff6726dc451444553411d81bf681dbcb03db409987de1db35c016f723" - digest: "sha256:f8b44694166c4358ee247362f0d3ec725c421bc681018ad8bea526250440b16b" + source_tree_digest: "sha256:4c7e5f20f939b7c78e0d3432c61d62f0b01d7fade56621491da307d656463448" + digest: "sha256:3b2e0e5abf1515b37407f609e7e35034136c5acea5ad46f37c67aac17f3ed93a" projection: - input_digest: "sha256:033f6d802a41abddbad4bb0375ca2ad899c2c5651409c3e9541b229179a4248c" - output_digest: "sha256:3ff85f46591c1c6d1479bb305adf866d64537f94345fac5185acba78b0738a6f" + input_digest: "sha256:924e25d3b7a56d3966975e735ec39f9eec2bca3420b0d4ee6af6e6e07d5191e0" + output_digest: "sha256:91c917a3497b68e1c5d6ab372a072a43f0e34f4815511b341357e5b0721b04aa" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:387375f2ea62d4971ae8051ad1d13aa3c0d42ef9e7695a6df40d636808cc06d6" + digest: "sha256:4f263ba0c7c0e71d2eaeacca25f1fbdaa43cac912d12a178b00dee26c051f932" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index c5bf77b..5269820 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.9.7-dev -# source-tree-digest: sha256:c8582e4ff6726dc451444553411d81bf681dbcb03db409987de1db35c016f723 -# input-digest: sha256:033f6d802a41abddbad4bb0375ca2ad899c2c5651409c3e9541b229179a4248c +# source-tree-digest: sha256:4c7e5f20f939b7c78e0d3432c61d62f0b01d7fade56621491da307d656463448 +# input-digest: sha256:924e25d3b7a56d3966975e735ec39f9eec2bca3420b0d4ee6af6e6e07d5191e0 # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b47b4..8833c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Versioning. ## [Unreleased] +- Resolve read-only harness detection, rendering, inspection, evaluation and + device reconciliation against the consumed engine module in external estates; + preserve estate-relative input paths and explicit mutation targets. + - Observe installed Devin CLI as the eighth catalogue identity while retaining seven verified execution adapters. Bind per-mapping runtime evidence policy and refuse unproven Devin configuration projections or runtime-proof claims. diff --git a/core/app/harness_adapter.go b/core/app/harness_adapter.go index 9c813e5..652ccd0 100644 --- a/core/app/harness_adapter.go +++ b/core/app/harness_adapter.go @@ -7,6 +7,7 @@ import ( "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" "github.com/NDDev-OpenNetwork/github-device-sync/core/harness" + "github.com/NDDev-OpenNetwork/github-device-sync/core/projections" ) func (services *Services) RenderHarnessAdapter( @@ -155,8 +156,9 @@ func (services *Services) EvaluateHarnessAdapter( } options.RuntimeDriver = normalizePath(options.RuntimeDriver) options.EvidenceDirectory = normalizePath(options.EvidenceDirectory) + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot run, findings := harness.Evaluate( - ctx, info.WorktreeRoot, harnessID, options, services.Schemas, services.Now(), nil, + ctx, engineRoot, harnessID, options, services.Schemas, services.Now(), nil, ) exitClass := domain.ExitSuccess if run.Result == "not-proven" { @@ -192,7 +194,8 @@ func (services *Services) resolveHarnessAdapter( envelope := envelopeForError(command, path, err) return nil, &envelope } - adapter, findings := harness.NewAdapter(info.WorktreeRoot, harnessID, services.Schemas) + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot + adapter, findings := harness.NewAdapter(engineRoot, harnessID, services.Schemas) if len(findings) != 0 { envelope := domain.NewEnvelope(command, classifyFindings(findings), nil, findings...) return nil, &envelope diff --git a/core/app/harness_external_estate_test.go b/core/app/harness_external_estate_test.go new file mode 100644 index 0000000..2fd43b8 --- /dev/null +++ b/core/app/harness_external_estate_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" + "github.com/NDDev-OpenNetwork/github-device-sync/core/harness" +) + +func TestHarnessObservationsUseConsumedEngineInExternalEstate(t *testing.T) { + _, current, _, _ := runtime.Caller(0) + source := filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")) + root := t.TempDir() + engine := filepath.Join(root, "modules", "github-device-sync") + for _, directory := range []string{"harnesses", "skills", "docs", "tests/harness"} { + if err := copyAppTestTree(source, engine, directory); err != nil { + t.Fatal(err) + } + } + runCompletionGraphGit(t, root, "init", "-q") + runCompletionGraphGit(t, root, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "fixture") + // An estate-local decoy must not replace the consumed catalogue. + if err := os.Mkdir(filepath.Join(root, "harnesses"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "harnesses", "capability-registry.yaml"), []byte("invalid: true\n"), 0o644); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + for _, name := range []string{"agy", "claude", "codex", "cursor-agent", "devin", "grok", "opencode", "pi"} { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n[ \"$1\" = --version ] || exit 42\nprintf 'fixture 1.2.3\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + home := t.TempDir() + t.Setenv("HOME", home) + services, err := NewServices(DefaultClock) + if err != nil { + t.Fatal(err) + } + request := harness.RenderRequest{SkillProfile: "core", Scope: "project"} + target := t.TempDir() + t.Run("detect-one-and-all", func(t *testing.T) { + one := services.DetectHarness(t.Context(), root, "devin") + if one.ExitClass != domain.ExitSuccess || one.Data.(harness.RuntimeObservation).Version != "fixture 1.2.3" { + t.Fatalf("external detection: %+v", one) + } + all := services.DetectHarness(t.Context(), root, "all") + if len(all.Data.(harness.RuntimeDetectionReport).Harnesses) != len(harness.CanonicalIDs) { + t.Fatalf("external catalogue not observed: %+v", all) + } + }) + t.Run("render-inspect-and-doctor", func(t *testing.T) { + baseline, findings := harness.NewAdapter(source, "codex", services.Schemas) + if len(findings) != 0 { + t.Fatal(findings) + } + want, findings := baseline.Render(request) + if len(findings) != 0 { + t.Fatal(findings) + } + render := services.RenderHarnessAdapter(t.Context(), root, "codex", request) + if render.ExitClass != domain.ExitSuccess || render.Data.(harness.AdapterCandidate).CandidateDigest != want.CandidateDigest { + t.Fatalf("external rendering differs from canonical source: %+v", render) + } + inspect := services.InspectHarnessAdapter(t.Context(), root, "codex", target, request) + if inspect.ExitClass != domain.ExitSuccess || inspect.Data.(harness.AdapterInspection).CandidateDigest != want.CandidateDigest { + t.Fatalf("external inspection: %+v", inspect) + } + doctor := services.DoctorHarnessAdapter(t.Context(), root, "codex", target, request) + if doctor.Data == nil || doctor.Data.(harness.AdapterDoctorReport).Runtime.Version != "fixture 1.2.3" { + t.Fatalf("external doctor: %+v", doctor) + } + }) + t.Run("installed-only-plan-remains-refused", func(t *testing.T) { + plan := services.PlanHarnessAdapter(t.Context(), root, "devin", target, request, "install") + if !appHasFinding(plan, "GDS_HARNESS_PROJECT_SKILLS_NOT_PROVEN") || plan.Mutation.Attempted { + t.Fatalf("unproven configuration was permitted: %+v", plan) + } + }) + t.Run("evaluation-without-model-execution", func(t *testing.T) { + result := services.EvaluateHarnessAdapter(t.Context(), root, "codex", harness.EvalOptions{ + SkillProfile: "core", ModelLabel: "not-proven", ExecutionProfile: "read-only", + }) + run, ok := result.Data.(harness.EvalRun) + if !ok || run.Profile.ID != "codex" || appHasFinding(result, "GDS_INPUT_READ_FAILED") || result.Mutation.Attempted { + t.Fatalf("external evaluation did not resolve the consumed contract: %+v", result) + } + }) + t.Run("device-reconciliation", func(t *testing.T) { + result := services.ReconcileDeviceHarnesses(t.Context(), HarnessSyncOptions{ + Path: root, DevicePath: filepath.Join(source, "estate", "devices", "example-user-ubuntu-1.yaml"), + TargetRoot: target, SkillProfile: "core", Scope: "project", + }) + if _, ok := result.Data.(HarnessSyncData); !ok || appHasFinding(result, "GDS_INPUT_READ_FAILED") { + t.Fatalf("external reconciliation did not inspect the consumed catalogue: %+v", result) + } + }) + for _, directory := range []string{home, target} { + entries, err := os.ReadDir(directory) + if err != nil || len(entries) != 0 { + t.Fatalf("read-only operation changed %s: %v %v", directory, entries, err) + } + } +} diff --git a/core/app/harness_sync.go b/core/app/harness_sync.go index 494bcbc..e12983d 100644 --- a/core/app/harness_sync.go +++ b/core/app/harness_sync.go @@ -9,6 +9,7 @@ import ( "github.com/NDDev-OpenNetwork/github-device-sync/core/domain" "github.com/NDDev-OpenNetwork/github-device-sync/core/harness" "github.com/NDDev-OpenNetwork/github-device-sync/core/materialize" + "github.com/NDDev-OpenNetwork/github-device-sync/core/projections" "github.com/NDDev-OpenNetwork/github-device-sync/core/workspace" ) @@ -71,6 +72,7 @@ func (services *Services) ReconcileDeviceHarnesses( // currently holds. Two selected harnesses that share a skill root collide on // an empty root too, where no on-disk evidence of the conflict exists yet. claims := map[string][]harness.AdapterFile{} + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot for _, id := range harness.CanonicalIDs { // A selected harness that cannot be rendered is a hard error: the device // cannot converge on it. An unselected one is skipped instead, so an @@ -78,7 +80,7 @@ func (services *Services) ReconcileDeviceHarnesses( // the same separation ValidateSelected already makes. It is recorded, not // swallowed: an unobservable entry could be a leftover install, and // removing what cannot be seen is never safe. - adapter, adapterFindings := harness.NewAdapter(info.WorktreeRoot, id, services.Schemas) + adapter, adapterFindings := harness.NewAdapter(engineRoot, id, services.Schemas) if len(adapterFindings) != 0 { if wanted[id] { return domain.NewEnvelope( diff --git a/core/app/services.go b/core/app/services.go index a2e2fce..ad00457 100644 --- a/core/app/services.go +++ b/core/app/services.go @@ -1323,10 +1323,11 @@ func (services *Services) DetectHarness( } var report any var findings []domain.Finding + engineRoot := projections.ResolveDevelopmentSourceLayout(info.WorktreeRoot).EngineRoot if harnessID == "all" { - report, findings = harness.DetectAll(ctx, info.WorktreeRoot, services.Schemas) + report, findings = harness.DetectAll(ctx, engineRoot, services.Schemas) } else { - report, findings = harness.Detect(ctx, info.WorktreeRoot, harnessID, services.Schemas) + report, findings = harness.Detect(ctx, engineRoot, harnessID, services.Schemas) } return domain.NewEnvelope( "gds harness detect", classifyFindings(findings), report, findings...,