diff --git a/manifest.json b/manifest.json index 6d3fd6f15..14aca3b8b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "_comment": "Platform template registry. Repos may be public or platform-private; CI and runtime template-cache refresh clone them with the SSOT-managed template read token, then strip .git metadata before use. Customer/private tenant templates remain outside this platform manifest.", - "_pinning_contract": "RFC #2927 — every entry's `ref` is pinned to an immutable commit SHA (not a branch like `main` and not a mutable tag). The previous `ref:main` exposure made provisioning non-reproducible — a merge to ANY template's `main` instantly reached every subsequent provision. Pinning restores: (a) reproducible identity (same SHA → same config.yaml + prompts + skills on every boot); (b) auditable provenance (the SHA is the artifact's content-address); (c) explicit upgrades (bumping a pin is a reviewed PR, not silent). CI test TestManifest_RefPinningCompleteness (workspace-server/internal/handlers/manifest_pinning_test.go) asserts the pinning contract: (1) every ref is a 40-char commit SHA, (2) every pinned SHA is reachable in the named repo, (3) workspace_template entries include config.yaml in the pinned ref's tree. To bump a pin: PR with the new SHA, tests run, driver reviews the diff. PLATFORM-AGENT IS NOT PINNED HERE: per #2919, the platform-agent template's `config.yaml` is being added in template PR #1; once merged AND config.yaml exists at the pinned SHA, add the entry here in a follow-up PR.", + "_pinning_contract": "RFC #2927 — every entry's `ref` is pinned to an immutable commit SHA (not a branch like `main` and not a mutable tag). The previous `ref:main` exposure made provisioning non-reproducible — a merge to ANY template's `main` instantly reached every subsequent provision. Pinning restores: (a) reproducible identity (same SHA → same config.yaml + prompts + skills on every boot); (b) auditable provenance (the SHA is the artifact's content-address); (c) explicit upgrades (bumping a pin is a reviewed PR, not silent). CI test TestManifest_RefPinningCompleteness (workspace-server/internal/handlers/manifest_pinning_test.go) asserts the pinning contract: (1) every ref is a 40-char commit SHA, (2) every pinned SHA is reachable in the named repo, (3) workspace_template entries include config.yaml in the pinned ref's tree. To bump a pin: PR with the new SHA, tests run, driver reviews the diff. The `platform-agent` entry is the IMAGE-BAKED template the workspace-server's Dockerfile.platform-agent COPYs at build time (RFC #2843 §10a); the drift-gate in platform_agent_image_drift_test.go asserts the image-baked content stays SSOT-equal to this pin.", "version": 1, "plugins": [ {"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "7a3cea71e684fe87fc2847e2b105301b552a9098"}, @@ -31,7 +31,8 @@ {"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "143e69b56f2530433141f5a87373e8a76578c52e"}, {"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "070447a0afdf66ae6f2bb166ac3e2b2884456951"}, {"name": "google-adk", "repo": "molecule-ai/molecule-ai-workspace-template-google-adk", "ref": "3f9fd7ef6ea4dd912bb65446607f3c3c991ea76e"}, - {"name": "seo-agent", "repo": "molecule-ai/molecule-ai-workspace-template-seo-agent", "ref": "51bee3c0de03c7d38ddc153e7b9dc70e19ededd6"} + {"name": "seo-agent", "repo": "molecule-ai/molecule-ai-workspace-template-seo-agent", "ref": "51bee3c0de03c7d38ddc153e7b9dc70e19ededd6"}, + {"name": "platform-agent", "repo": "molecule-ai/molecule-ai-workspace-template-platform-agent", "ref": "89f51c6cb8cc2dc4d15b6ac9fa370113b63cc594"} ], "org_templates": [ {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "990d7b23f65dadd7afe05958a77eeb74082b4feb"}, diff --git a/workspace-server/internal/handlers/manifest_pinning_test.go b/workspace-server/internal/handlers/manifest_pinning_test.go index 36d7a8f69..a0ae2ea7e 100644 --- a/workspace-server/internal/handlers/manifest_pinning_test.go +++ b/workspace-server/internal/handlers/manifest_pinning_test.go @@ -296,3 +296,175 @@ func TestManifest_RefPinning_WorkspaceTemplatesIncludeConfigYAML(t *testing.T) { } } } + +// TestManifest_RefPinning_AllRefsAreAncestorOfDefaultBranch is the +// load-bearing MERGED-INTO-MAIN gate (CR2's #2959 test-gap fix, +// updated by CR2 RC 12143 to use the parent-of-first-commit-in-main +// assertion — the prior "404→fail, 200→pass" shape AND the +// "merge_base_commit == pinned_ref" shape were both false-passes: +// Gitea's /compare/{base}...{head} returns 200 for DIVERGED/ +// unmerged branches too — only 404s when refs share NO history — +// AND Gitea 1.26.2's compare response does NOT include a +// merge_base_commit field at all (verified live: `{"total_commits":N, +// "commits":[…]}` — no merge_base_commit). CR2 verified live: +// GET /compare/89f51c6c...main → HTTP 200 (total_commits:1) even +// though 89f51c6c is an unmerged PR-branch tip — the prior +// guard test PASSED the exact pin it should reject = no-op guard). +// +// A pinned ref that is NOT an ancestor of the default branch is a +// PR-branch head (not yet merged) — the partial-template / +// content-drift class of bug rides on this gap: the prior pinning +// contract asserted (1) 40-char SHA, (2) reachable, (3) config.yaml +// in tree. All three pass for an unmerged PR-branch head whose +// branch tip happens to carry config.yaml (template-platform-agent +// PR #1 was exactly this shape — the PR #2959 pin to 89f51c6c +// passed the existing 3-clause gate; the corrected 4th clause +// catches it pre-merge). +// +// The 4th clause (corrected for the Gitea 1.26.2 response shape): +// Gitea 1.26.2's /compare/{base}...{head} response is +// {total_commits: N, commits: [{sha, parents}, ...]} (no +// merge_base_commit; verified live). The TRUE-ancestry check uses +// the commits array directly: +// +// - For a MERGED pin (pin is in main's history): commits = the +// commits in main NOT in pin's reachable set. The first +// commit in the array is the OLDEST commit in main not +// reachable from pin — i.e. the commit IMMEDIATELY AFTER +// the pin in main's history. Its parent includes the +// pin (the pin is the most recent common ancestor). +// +// - For an UNMERGED PR-branch tip (pin is on a different +// branch that has NOT been merged into main): commits = +// [the branch point commit, ...]. The first commit is the +// branch point — its parent is the merge-base (a +// different SHA from the pin). The pin is NOT in main's +// history. +// +// The check: for each commit in the array, look for the pinned +// ref in its parents. If found, the pin is in main's history +// (a true ancestor). If we exhaust the array without finding +// it, the pin is on a different branch → unmerged PR-branch +// tip. The empty-commits case (pin == main HEAD) is a +// degenerate ancestor and passes. +// +// Skips if Gitea isn't reachable (offline CI). +func TestManifest_RefPinning_AllRefsAreAncestorOfDefaultBranch(t *testing.T) { + if !giteaReachableForTest() { + t.Skip("Gitea unreachable (offline CI lane); skipping dynamic pinning ancestor-of-default-branch test") + } + data, err := readRealManifestForPinningTest(t) + if err != nil { + t.Skipf("manifest.json not readable: %v", err) + } + var m struct { + Plugins []manifestEntry `json:"plugins"` + WorkspaceTemplates []manifestEntry `json:"workspace_templates"` + OrgTemplates []manifestEntry `json:"org_templates"` + } + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("manifest parse: %v", err) + } + all := append(append([]manifestEntry{}, m.Plugins...), m.WorkspaceTemplates...) + all = append(all, m.OrgTemplates...) + if len(all) == 0 { + t.Fatal("no manifest entries (test invariant broken)") + } + + client := &http.Client{Timeout: 10 * time.Second} + auth := giteaBasicAuthForTest(t) + for _, e := range all { + // Gitea compare API: GET /repos/{owner}/{repo}/compare/{base}...{head} + // base = pinned SHA, head = default branch (main). Gitea + // 1.26.2 returns: {total_commits: N, commits: [{sha, parents}, ...]} + // (no top-level merge_base_commit; verified live). The + // commits array contains commits reachable from head but + // NOT from base. The first commit is the OLDEST such + // commit (immediately after base in head's history for a + // merged base; the branch point for an unmerged base). + url := "https://git.moleculesai.app/api/v1/repos/" + e.Repo + "/compare/" + e.Ref + "..." + "main" + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", auth) + resp, err := client.Do(req) + if err != nil { + t.Errorf("entry %q (%s): compare %s...main failed: %v", e.Name, e.Repo, e.Ref, err) + continue + } + if resp.StatusCode == 404 { + // 404 from Gitea compare = the pinned ref shares NO + // history with main (a fork or unrelated branch). + // Surface a descriptive error. + t.Errorf("entry %q (%s): pinned ref %q is NOT an ancestor of the default branch (main) — /compare returned 404 (no shared history). Either: (a) bump the pin to a merged SHA, OR (b) the repo is unrelated to this template — the entry is mis-configured.", e.Name, e.Repo, e.Ref) + resp.Body.Close() + continue + } + if resp.StatusCode != 200 { + t.Errorf("entry %q (%s): compare %s...main returned HTTP %d", e.Name, e.Repo, e.Ref, resp.StatusCode) + resp.Body.Close() + continue + } + // Parse the response. + var cmp struct { + Commits []struct { + SHA string `json:"sha"` + Parents []struct { + SHA string `json:"sha"` + } `json:"parents"` + } `json:"commits"` + } + if err := json.NewDecoder(resp.Body).Decode(&cmp); err != nil { + t.Errorf("entry %q (%s): compare JSON parse failed: %v", e.Name, e.Repo, err) + resp.Body.Close() + continue + } + resp.Body.Close() + + // Degenerate ancestor case: pin == main HEAD. The diff is + // empty (commits=[]), and a pin that's main HEAD is + // trivially its own ancestor. Pass. + if len(cmp.Commits) == 0 { + continue + } + + // TRUE-ancestry check: walk the commits array and for + // each commit, check if any of its parents equals the + // pinned ref. If we find a parent match, the pin is in + // main's history (a true ancestor). If we exhaust the + // array without a match, the pin is on a different + // branch (the first commit is the branch point, NOT a + // child of the pin) → unmerged PR-branch tip. + // + // Bounded loop: cap at 1000 commits to avoid + // pathological repos. Real first-parent walks are + // 1-10 commits for a typical + // first-commit-is-immediately-after-base case. + ancestor := false + checked := 0 + const maxWalk = 1000 + for _, c := range cmp.Commits { + checked++ + if checked > maxWalk { + break + } + for _, p := range c.Parents { + if p.SHA == e.Ref { + ancestor = true + break + } + } + if ancestor { + break + } + } + + if !ancestor { + // The first commit in the array is the diagnostic: + // show the operator what the first commit in main's + // "not in pin" set is — for a merged pin, this is + // the commit IMMEDIATELY AFTER the pin; for an + // unmerged pin, this is the branch point. + firstCommit := cmp.Commits[0] + t.Errorf("entry %q (%s): pinned ref %q is NOT an ancestor of the default branch (main) — the first commit in the /compare diff is %q (parent: %v). For a MERGED pin, the first commit's parent would be the pinned ref itself; for an UNMERGED PR-branch tip, the first commit is the branch point (a different SHA). This is the unmerged-PR-branch-head landmine: a provisioning today would land a PR-branch tip that has NOT been merged into main, the content is subject to force-push / rebase / deletion, and a future deploy of the same name can collide with the prior content. Either: (a) wait for the PR to merge into main, OR (b) bump the pin to a merged SHA. The 3 prior clauses (40-char / reachable / config.yaml-in-tree) all pass for an unmerged PR tip — this 4th clause (parent-of-first-commit-in-main == pinned_ref) is the load-bearing TRUE-ancestry gate.", e.Name, e.Repo, e.Ref, firstCommit.SHA, firstCommit.Parents) + } + } +}