Skip to content

Commit f21436b

Browse files
ggallenclaude
andcommitted
refactor(scaffold): add base URL generation and content hashing for harness templates
Add infrastructure for generating integrity-verified base URLs pointing to upstream scaffold harness templates. This is the foundation for PR 4 (install generates thin wrapper harnesses with base: references). ADR-0045 Phase 2, PR 3. Signed-off-by: Greg Allen <gallen@redhat.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent cc4b5b6 commit f21436b

3 files changed

Lines changed: 285 additions & 16 deletions

File tree

docs/plans/adr-0045-forge-portable-harness-phase2.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,23 +171,23 @@ PRs 1, 2, 3, 5 can start in parallel. PR 4 depends on PR 3 (needs the base URL b
171171

172172
**Create `internal/scaffold/baseurl.go`:**
173173

174-
- `ScaffoldBaseURL(harnessName, commitSHA string) string`:
174+
- `HarnessBaseURL(harnessName, commitSHA string) (string, error)`:
175175
- Returns `https://raw.githubusercontent.com/fullsend-ai/fullsend/<commitSHA>/internal/scaffold/fullsend-repo/harness/<harnessName>.yaml`
176176
- Validates `harnessName` matches `^[a-z][a-z0-9_-]*$` (same pattern as role validation)
177177
- Validates `commitSHA` is a 40-character hex string
178178
- No hash fragment -- the caller appends `#sha256=...` after computing the content hash
179179

180-
- `ScaffoldContentHash(harnessName string) (string, error)`:
180+
- `HarnessContentHash(harnessName string) (string, error)`:
181181
- Reads the embedded harness file from `fullsend-repo/harness/<harnessName>.yaml` via the embedded `embed.FS`
182182
- Returns the SHA-256 hex digest of the raw file content
183183
- This hash is the integrity hash that goes into the `#sha256=...` URL fragment
184184
- The hash is computed from the compile-time embedded content, which matches what `raw.githubusercontent.com` serves for the release tag's commit SHA
185185

186-
- `ScaffoldBaseURLWithHash(harnessName, commitSHA string) (string, error)`:
187-
- Convenience wrapper: calls `ScaffoldBaseURL` + `ScaffoldContentHash` and returns the full URL with `#sha256=...` fragment
186+
- `HarnessBaseURLWithHash(harnessName, commitSHA string) (string, error)`:
187+
- Convenience wrapper: calls `HarnessBaseURL` + `HarnessContentHash` and returns the full URL with `#sha256=...` fragment
188188
- Returns an error if the harness name does not exist in the embedded scaffold
189189

190-
- `ScaffoldHarnessNames() []string`:
190+
- `HarnessNames() ([]string, error)`:
191191
- Returns the list of harness names available in the embedded scaffold (e.g., `["code", "fix", "prioritize", "retro", "review", "triage"]`)
192192
- Derived from `fullsend-repo/harness/*.yaml` in the embedded FS
193193
- Sorted alphabetically
@@ -201,12 +201,12 @@ PRs 1, 2, 3, 5 can start in parallel. PR 4 depends on PR 3 (needs the base URL b
201201
- **The `allowed_remote_resources` prefix:** Generated base URLs use the `https://raw.githubusercontent.com/fullsend-ai/fullsend/` prefix. The install flow must add this prefix to `config.yaml`'s `allowed_remote_resources` if not already present (handled in PR 4).
202202

203203
**Create `internal/scaffold/baseurl_test.go`:**
204-
- `ScaffoldBaseURL` returns expected URL format
205-
- `ScaffoldBaseURL` rejects invalid harness names and commit SHAs
206-
- `ScaffoldContentHash` returns a 64-character hex string for each known harness
207-
- `ScaffoldContentHash` errors on unknown harness name
208-
- `ScaffoldBaseURLWithHash` produces a valid URL with `#sha256=...` fragment
209-
- `ScaffoldHarnessNames` returns the expected set of names, sorted
204+
- `HarnessBaseURL` returns expected URL format
205+
- `HarnessBaseURL` rejects invalid harness names and commit SHAs
206+
- `HarnessContentHash` returns a 64-character hex string for each known harness
207+
- `HarnessContentHash` errors on unknown harness name
208+
- `HarnessBaseURLWithHash` produces a valid URL with `#sha256=...` fragment
209+
- `HarnessNames` returns the expected set of names, sorted
210210
- Hash stability: hash of a known harness matches `sha256sum` of the embedded file content
211211

212212
**After merge:** The scaffold package can generate integrity-verified base URLs for any embedded harness template. No install flow changes yet.
@@ -230,7 +230,7 @@ The `WorkflowsLayer` currently uses `scaffold.WalkFullsendRepo()` which skips `l
230230
**`Install() error`:**
231231
1. For each agent in `agents`:
232232
- Derive the harness name from `agent.Role`. Special case: role `"coder"` maps to harness name `"code"` (matching the existing scaffold convention where `code.yaml` has `role: coder`). Role `"fullsend"` is the org-level app and has no harness -- skip it.
233-
- Call `scaffold.ScaffoldBaseURLWithHash(harnessName, commitSHA)` to get the base URL
233+
- Call `scaffold.HarnessBaseURLWithHash(harnessName, commitSHA)` to get the base URL
234234
- Generate wrapper YAML:
235235
```yaml
236236
# This file is managed by fullsend. Do not edit it directly.
@@ -363,7 +363,7 @@ The `HarnessWrappersLayer` maintains this mapping. A helper function `harnessNam
363363
- `Base` is empty (consumed by LoadWithBase)
364364

365365
2. **All scaffold templates through forge.github resolution:**
366-
- For each harness in `scaffold.ScaffoldHarnessNames()`:
366+
- For each harness in `scaffold.HarnessNames()`:
367367
- Load via `LoadWithOpts` with `ForgePlatform: "github"`
368368
- Verify `PreScript != ""` and `PostScript != ""` (they come from `forge.github`)
369369
- Verify `RunnerEnv` is non-empty and contains expected keys
@@ -383,9 +383,9 @@ The `HarnessWrappersLayer` maintains this mapping. A helper function `harnessNam
383383
- Verify sorting by role
384384

385385
5. **Base URL integrity:**
386-
- For each harness, compute `ScaffoldContentHash(name)`
386+
- For each harness, compute `HarnessContentHash(name)`
387387
- Load the embedded file directly from `embed.FS`, compute `sha256.Sum256`, compare
388-
- Verify `ScaffoldBaseURLWithHash` produces a URL whose hash fragment matches
388+
- Verify `HarnessBaseURLWithHash` produces a URL whose hash fragment matches
389389

390390
**Update `internal/scaffold/scaffold_test.go`:**
391391

@@ -405,7 +405,7 @@ After all PRs merge, verify Phase 2 end-to-end:
405405
4. **Scaffold templates:** Each template in `internal/scaffold/fullsend-repo/harness/` has `forge.github:` block with `pre_script`, `post_script`, and GitHub-specific `runner_env` keys. Platform-neutral `runner_env` keys remain at top level.
406406
5. **Forge resolution:** `LoadWithOpts(path, {ForgePlatform: "github"})` on each scaffold template produces the same effective config as the pre-Phase-2 templates (same `PreScript`, `PostScript`, `RunnerEnv` key set). Verify with a comparison test.
407407
6. **DiscoverAgents:** `DiscoverAgents(scaffoldHarnessDir)` returns 6 agents with correct role/slug pairs: `coder/fullsend-ai-coder` (code.yaml), `coder/fullsend-ai-coder` (fix.yaml), `prioritize/fullsend-ai-prioritize`, `retro/fullsend-ai-retro`, `review/fullsend-ai-review`, `triage/fullsend-ai-triage`.
408-
7. **Base URLs:** `ScaffoldBaseURLWithHash("triage", "<sha>")` returns a well-formed URL with `#sha256=...` that matches the embedded file's hash.
408+
7. **Base URLs:** `HarnessBaseURLWithHash("triage", "<sha>")` returns a well-formed URL with `#sha256=...` that matches the embedded file's hash.
409409
8. **Wrapper generation:** Simulated install produces wrapper YAML files that parse correctly via `LoadRaw()` and contain expected `base:`, `role:`, `slug:` fields.
410410
9. **Wrapper loading:** Wrapper YAML loaded via `LoadWithBase()` with `ForgePlatform: "github"` produces a fully-populated harness with all fields from the base scaffold resolved.
411411
10. **Lock --all:** `fullsend lock --all` in a directory with wrapper harnesses records all base URL dependencies in `lock.yaml`.

internal/scaffold/baseurl.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package scaffold
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"fmt"
7+
"io/fs"
8+
"regexp"
9+
"sort"
10+
"strings"
11+
)
12+
13+
const (
14+
harnessBaseURLPrefix = "https://raw.githubusercontent.com/fullsend-ai/fullsend/"
15+
harnessURLPath = "internal/scaffold/fullsend-repo/harness/"
16+
)
17+
18+
var (
19+
validHarnessName = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
20+
validCommitSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
21+
)
22+
23+
// HarnessBaseURL returns the raw.githubusercontent.com URL for a scaffold
24+
// harness template at a specific commit SHA. The URL does not include an
25+
// integrity hash fragment — use HarnessBaseURLWithHash for that.
26+
func HarnessBaseURL(harnessName, commitSHA string) (string, error) {
27+
if !validHarnessName.MatchString(harnessName) {
28+
return "", fmt.Errorf("invalid harness name %q: must match %s", harnessName, validHarnessName.String())
29+
}
30+
if !validCommitSHA.MatchString(commitSHA) {
31+
return "", fmt.Errorf("invalid commit SHA %q: must be a 40-character lowercase hex string", commitSHA)
32+
}
33+
return harnessBaseURLPrefix + commitSHA + "/" + harnessURLPath + harnessName + ".yaml", nil
34+
}
35+
36+
// HarnessContentHash returns the SHA-256 hex digest of the embedded scaffold
37+
// harness template. This hash matches what raw.githubusercontent.com serves
38+
// for the release commit the CLI was built from.
39+
func HarnessContentHash(harnessName string) (string, error) {
40+
if !validHarnessName.MatchString(harnessName) {
41+
return "", fmt.Errorf("invalid harness name %q: must match %s", harnessName, validHarnessName.String())
42+
}
43+
data, err := content.ReadFile("fullsend-repo/harness/" + harnessName + ".yaml")
44+
if err != nil {
45+
return "", fmt.Errorf("unknown harness %q: %w", harnessName, err)
46+
}
47+
sum := sha256.Sum256(data)
48+
return hex.EncodeToString(sum[:]), nil
49+
}
50+
51+
// HarnessBaseURLWithHash returns the full base URL for a scaffold harness
52+
// template, including the #sha256=... integrity hash fragment.
53+
func HarnessBaseURLWithHash(harnessName, commitSHA string) (string, error) {
54+
base, err := HarnessBaseURL(harnessName, commitSHA)
55+
if err != nil {
56+
return "", err
57+
}
58+
hash, err := HarnessContentHash(harnessName)
59+
if err != nil {
60+
return "", err
61+
}
62+
return base + "#sha256=" + hash, nil
63+
}
64+
65+
// HarnessNames returns the sorted list of harness template names
66+
// available in the embedded scaffold (e.g., ["code", "fix", "triage"]).
67+
func HarnessNames() ([]string, error) {
68+
entries, err := fs.ReadDir(content, "fullsend-repo/harness")
69+
if err != nil {
70+
return nil, fmt.Errorf("reading embedded harness directory: %w", err)
71+
}
72+
var names []string
73+
for _, e := range entries {
74+
if e.IsDir() {
75+
continue
76+
}
77+
if name := e.Name(); strings.HasSuffix(name, ".yaml") {
78+
names = append(names, strings.TrimSuffix(name, ".yaml"))
79+
}
80+
}
81+
sort.Strings(names)
82+
return names, nil
83+
}

internal/scaffold/baseurl_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package scaffold
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestHarnessBaseURL(t *testing.T) {
14+
sha := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
15+
16+
t.Run("valid inputs", func(t *testing.T) {
17+
url, err := HarnessBaseURL("triage", sha)
18+
require.NoError(t, err)
19+
assert.Equal(t,
20+
"https://raw.githubusercontent.com/fullsend-ai/fullsend/"+sha+"/internal/scaffold/fullsend-repo/harness/triage.yaml",
21+
url)
22+
})
23+
24+
t.Run("hyphenated name", func(t *testing.T) {
25+
url, err := HarnessBaseURL("my-agent", sha)
26+
require.NoError(t, err)
27+
assert.Contains(t, url, "/my-agent.yaml")
28+
})
29+
30+
t.Run("invalid harness name uppercase", func(t *testing.T) {
31+
_, err := HarnessBaseURL("Triage", sha)
32+
assert.Error(t, err)
33+
assert.Contains(t, err.Error(), "invalid harness name")
34+
})
35+
36+
t.Run("invalid harness name empty", func(t *testing.T) {
37+
_, err := HarnessBaseURL("", sha)
38+
assert.Error(t, err)
39+
})
40+
41+
t.Run("invalid harness name starts with digit", func(t *testing.T) {
42+
_, err := HarnessBaseURL("1agent", sha)
43+
assert.Error(t, err)
44+
})
45+
46+
t.Run("invalid harness name special chars", func(t *testing.T) {
47+
_, err := HarnessBaseURL("agent.name", sha)
48+
assert.Error(t, err)
49+
})
50+
51+
t.Run("invalid commit SHA too short", func(t *testing.T) {
52+
_, err := HarnessBaseURL("triage", "abc123")
53+
assert.Error(t, err)
54+
assert.Contains(t, err.Error(), "invalid commit SHA")
55+
})
56+
57+
t.Run("invalid commit SHA uppercase", func(t *testing.T) {
58+
_, err := HarnessBaseURL("triage", "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2")
59+
assert.Error(t, err)
60+
})
61+
62+
t.Run("invalid commit SHA wrong length", func(t *testing.T) {
63+
_, err := HarnessBaseURL("triage", strings.Repeat("a", 39))
64+
assert.Error(t, err)
65+
})
66+
}
67+
68+
func TestHarnessContentHash(t *testing.T) {
69+
t.Run("known harness returns 64-char hex", func(t *testing.T) {
70+
hash, err := HarnessContentHash("triage")
71+
require.NoError(t, err)
72+
assert.Len(t, hash, 64)
73+
assert.Regexp(t, `^[0-9a-f]{64}$`, hash)
74+
})
75+
76+
t.Run("unknown harness errors", func(t *testing.T) {
77+
_, err := HarnessContentHash("nonexistent")
78+
assert.Error(t, err)
79+
assert.Contains(t, err.Error(), "unknown harness")
80+
})
81+
82+
t.Run("invalid harness name errors", func(t *testing.T) {
83+
_, err := HarnessContentHash("INVALID")
84+
assert.Error(t, err)
85+
assert.Contains(t, err.Error(), "invalid harness name")
86+
})
87+
88+
t.Run("hash matches manual computation", func(t *testing.T) {
89+
data, err := content.ReadFile("fullsend-repo/harness/triage.yaml")
90+
require.NoError(t, err)
91+
sum := sha256.Sum256(data)
92+
expected := hex.EncodeToString(sum[:])
93+
94+
hash, err := HarnessContentHash("triage")
95+
require.NoError(t, err)
96+
assert.Equal(t, expected, hash)
97+
})
98+
99+
t.Run("each harness has unique hash", func(t *testing.T) {
100+
names, err := HarnessNames()
101+
require.NoError(t, err)
102+
hashes := make(map[string]string)
103+
for _, name := range names {
104+
hash, err := HarnessContentHash(name)
105+
require.NoError(t, err, "hashing %s", name)
106+
if prev, dup := hashes[hash]; dup {
107+
t.Errorf("harness %q has same hash as %q", name, prev)
108+
}
109+
hashes[hash] = name
110+
}
111+
})
112+
}
113+
114+
func TestHarnessBaseURLWithHash(t *testing.T) {
115+
sha := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
116+
117+
t.Run("produces URL with hash fragment", func(t *testing.T) {
118+
url, err := HarnessBaseURLWithHash("triage", sha)
119+
require.NoError(t, err)
120+
assert.Contains(t, url, "#sha256=")
121+
122+
parts := strings.SplitN(url, "#sha256=", 2)
123+
require.Len(t, parts, 2)
124+
assert.Len(t, parts[1], 64, "hash fragment should be 64 hex chars")
125+
assert.Regexp(t, `^[0-9a-f]{64}$`, parts[1])
126+
})
127+
128+
t.Run("hash fragment matches content hash", func(t *testing.T) {
129+
url, err := HarnessBaseURLWithHash("code", sha)
130+
require.NoError(t, err)
131+
132+
hash, err := HarnessContentHash("code")
133+
require.NoError(t, err)
134+
135+
assert.True(t, strings.HasSuffix(url, "#sha256="+hash))
136+
})
137+
138+
t.Run("invalid harness name errors", func(t *testing.T) {
139+
_, err := HarnessBaseURLWithHash("INVALID", sha)
140+
assert.Error(t, err)
141+
})
142+
143+
t.Run("unknown harness errors", func(t *testing.T) {
144+
_, err := HarnessBaseURLWithHash("nonexistent", sha)
145+
assert.Error(t, err)
146+
assert.Contains(t, err.Error(), "unknown harness")
147+
})
148+
149+
t.Run("invalid commit SHA errors", func(t *testing.T) {
150+
_, err := HarnessBaseURLWithHash("triage", "bad")
151+
assert.Error(t, err)
152+
})
153+
}
154+
155+
func TestHarnessNames(t *testing.T) {
156+
names, err := HarnessNames()
157+
require.NoError(t, err)
158+
159+
t.Run("returns expected harnesses", func(t *testing.T) {
160+
expected := []string{"code", "fix", "prioritize", "retro", "review", "triage"}
161+
assert.Equal(t, expected, names)
162+
})
163+
164+
t.Run("is sorted", func(t *testing.T) {
165+
for i := 1; i < len(names); i++ {
166+
assert.True(t, names[i-1] < names[i],
167+
"names should be sorted: %q >= %q", names[i-1], names[i])
168+
}
169+
})
170+
}
171+
172+
func TestHarnessBaseURLWithHashAllHarnesses(t *testing.T) {
173+
sha := "abcdef0123456789abcdef0123456789abcdef01"
174+
175+
names, err := HarnessNames()
176+
require.NoError(t, err)
177+
178+
for _, name := range names {
179+
t.Run(name, func(t *testing.T) {
180+
url, err := HarnessBaseURLWithHash(name, sha)
181+
require.NoError(t, err)
182+
assert.True(t, strings.HasPrefix(url, "https://"))
183+
assert.Contains(t, url, "/"+name+".yaml#sha256=")
184+
})
185+
}
186+
}

0 commit comments

Comments
 (0)