fix(manifest#2927): pin platform-agent workspace template (RFC #2927 follow-up) #2959

Closed
agent-dev-b wants to merge 3 commits from fix/2927-platform-agent-manifest-entry into main
2 changed files with 175 additions and 2 deletions
+3 -2
View File
@@ -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"},
@@ -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)
}
}
}