| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // skillsFixture is one served (path, body) pair plus the digest it produces, |
| 15 | // so tests can build an index without hardcoding sha256 hex by hand. |
| 16 | type skillsFixture struct { |
| 17 | path string |
| 18 | body string |
| 19 | } |
| 20 | |
| 21 | func (f skillsFixture) digest() string { |
| 22 | sum := sha256.Sum256([]byte(f.body)) |
| 23 | return "sha256:" + hex.EncodeToString(sum[:]) |
| 24 | } |
| 25 | |
| 26 | // skillsServer serves index.json at indexPath (built from the fixtures, with |
| 27 | // url fields site-relative as the real index publishes them) plus each |
| 28 | // fixture's own bytes at its own path. |
| 29 | func skillsServer(t *testing.T, indexPath string, fixtures ...skillsFixture) *httptest.Server { |
| 30 | t.Helper() |
| 31 | mux := http.NewServeMux() |
| 32 | type entry struct { |
| 33 | Name string `json:"name"` |
| 34 | Type string `json:"type"` |
| 35 | Description string `json:"description"` |
| 36 | URL string `json:"url"` |
| 37 | Digest string `json:"digest"` |
| 38 | } |
| 39 | var idx struct { |
| 40 | Schema string `json:"$schema"` |
| 41 | Notice string `json:"//"` |
| 42 | Skills []entry `json:"skills"` |
| 43 | } |
| 44 | idx.Schema = "https://example.com/schema.json" |
| 45 | idx.Notice = "generated — do not edit" |
| 46 | for i, f := range fixtures { |
| 47 | name := fmt.Sprintf("skill-%d", i) |
| 48 | idx.Skills = append(idx.Skills, entry{ |
| 49 | Name: name, |
| 50 | Type: "skill-md", |
| 51 | Description: "does the " + name + " thing", |
| 52 | URL: f.path, |
| 53 | Digest: f.digest(), |
| 54 | }) |
| 55 | body := f.body |
| 56 | mux.HandleFunc(f.path, func(w http.ResponseWriter, r *http.Request) { |
| 57 | w.Write([]byte(body)) |
| 58 | }) |
| 59 | } |
| 60 | raw, err := json.Marshal(idx) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | mux.HandleFunc(indexPath, func(w http.ResponseWriter, r *http.Request) { |
| 65 | w.Write(raw) |
| 66 | }) |
| 67 | ts := httptest.NewServer(mux) |
| 68 | t.Cleanup(ts.Close) |
| 69 | return ts |
| 70 | } |
| 71 | |
| 72 | func TestSkillsListPrintsNamesAndAbsoluteURLs(t *testing.T) { |
| 73 | f1 := skillsFixture{path: "/.well-known/agent-skills/one/SKILL.md", body: "# one\nrecipe one\n"} |
| 74 | f2 := skillsFixture{path: "/.well-known/agent-skills/two/SKILL.md", body: "# two\nrecipe two\n"} |
| 75 | ts := skillsServer(t, "/.well-known/agent-skills/index.json", f1, f2) |
| 76 | indexURL := ts.URL + "/.well-known/agent-skills/index.json" |
| 77 | |
| 78 | out := captureStdout(t) |
| 79 | runSkills([]string{"-index", indexURL}) |
| 80 | |
| 81 | got := out.String() |
| 82 | for i, f := range []skillsFixture{f1, f2} { |
| 83 | name := fmt.Sprintf("skill-%d", i) |
| 84 | if !strings.Contains(got, name) { |
| 85 | t.Errorf("output missing name %q:\n%s", name, got) |
| 86 | } |
| 87 | if !strings.Contains(got, "does the "+name+" thing") { |
| 88 | t.Errorf("output missing description for %q:\n%s", name, got) |
| 89 | } |
| 90 | if !strings.Contains(got, ts.URL+f.path) { |
| 91 | t.Errorf("output missing absolute url %q:\n%s", ts.URL+f.path, got) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestSkillsFetchWritesExactBytesOnDigestMatch(t *testing.T) { |
| 97 | body := "---\nname: getting-started\n---\n\nDo the thing.\n" |
| 98 | f := skillsFixture{path: "/.well-known/agent-skills/getting-started/SKILL.md", body: body} |
| 99 | ts := skillsServer(t, "/.well-known/agent-skills/index.json", f) |
| 100 | indexURL := ts.URL + "/.well-known/agent-skills/index.json" |
| 101 | |
| 102 | out := captureStdout(t) |
| 103 | runSkills([]string{"-index", indexURL, "skill-0"}) |
| 104 | |
| 105 | if out.String() != body { |
| 106 | t.Fatalf("stdout = %q, want the served bytes verbatim %q", out.String(), body) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestFetchSkillDigestMismatchNamesBothDigests(t *testing.T) { |
| 111 | served := skillsFixture{path: "/skills/x/SKILL.md", body: "actual bytes on the wire"} |
| 112 | // The index claims a digest that does not match what the url actually |
| 113 | // serves — simulating tampering or a stale index. |
| 114 | claimed := "sha256:" + strings.Repeat("00", 32) |
| 115 | |
| 116 | type entry struct { |
| 117 | Name string `json:"name"` |
| 118 | Type string `json:"type"` |
| 119 | Description string `json:"description"` |
| 120 | URL string `json:"url"` |
| 121 | Digest string `json:"digest"` |
| 122 | } |
| 123 | var idx struct { |
| 124 | Skills []entry `json:"skills"` |
| 125 | } |
| 126 | idx.Skills = []entry{{Name: "x", Type: "skill-md", Description: "d", URL: served.path, Digest: claimed}} |
| 127 | raw, err := json.Marshal(idx) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | mux := http.NewServeMux() |
| 132 | mux.HandleFunc("/index.json", func(w http.ResponseWriter, r *http.Request) { w.Write(raw) }) |
| 133 | mux.HandleFunc(served.path, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(served.body)) }) |
| 134 | ts := httptest.NewServer(mux) |
| 135 | t.Cleanup(ts.Close) |
| 136 | |
| 137 | _, err = fetchSkill(ts.URL+"/index.json", "x") |
| 138 | if err == nil { |
| 139 | t.Fatal("want an error on digest mismatch, got nil") |
| 140 | } |
| 141 | if !strings.Contains(err.Error(), claimed) || !strings.Contains(err.Error(), served.digest()) { |
| 142 | t.Fatalf("error %q does not name both the claimed and computed digests", err.Error()) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestFetchSkillUnknownNameListsAvailable(t *testing.T) { |
| 147 | f1 := skillsFixture{path: "/a/SKILL.md", body: "a"} |
| 148 | f2 := skillsFixture{path: "/b/SKILL.md", body: "b"} |
| 149 | ts := skillsServer(t, "/index.json", f1, f2) |
| 150 | indexURL := ts.URL + "/index.json" |
| 151 | |
| 152 | _, err := fetchSkill(indexURL, "does-not-exist") |
| 153 | if err == nil { |
| 154 | t.Fatal("want an error for an unknown skill name, got nil") |
| 155 | } |
| 156 | if !strings.Contains(err.Error(), "skill-0") || !strings.Contains(err.Error(), "skill-1") { |
| 157 | t.Fatalf("error %q does not list the available names", err.Error()) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestSkillsResolvesSiteRelativeURLAgainstIndexURL(t *testing.T) { |
| 162 | body := "the recipe" |
| 163 | mux := http.NewServeMux() |
| 164 | type entry struct { |
| 165 | Name string `json:"name"` |
| 166 | Type string `json:"type"` |
| 167 | Description string `json:"description"` |
| 168 | URL string `json:"url"` |
| 169 | Digest string `json:"digest"` |
| 170 | } |
| 171 | var idx struct { |
| 172 | Skills []entry `json:"skills"` |
| 173 | } |
| 174 | sum := sha256.Sum256([]byte(body)) |
| 175 | digest := "sha256:" + hex.EncodeToString(sum[:]) |
| 176 | // Site-relative (leading slash): must resolve against the server root, |
| 177 | // NOT against /prefix/, even though the index itself lives under /prefix/. |
| 178 | idx.Skills = []entry{{Name: "x", Type: "skill-md", Description: "d", URL: "/skills/x/SKILL.md", Digest: digest}} |
| 179 | raw, err := json.Marshal(idx) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | mux.HandleFunc("/prefix/index.json", func(w http.ResponseWriter, r *http.Request) { w.Write(raw) }) |
| 184 | requestedRootPath := false |
| 185 | mux.HandleFunc("/skills/x/SKILL.md", func(w http.ResponseWriter, r *http.Request) { |
| 186 | requestedRootPath = true |
| 187 | w.Write([]byte(body)) |
| 188 | }) |
| 189 | // A wrong resolution would ask for this instead — must never be hit. |
| 190 | mux.HandleFunc("/prefix/skills/x/SKILL.md", func(w http.ResponseWriter, r *http.Request) { |
| 191 | t.Error("resolved the url against the index's directory instead of the site root") |
| 192 | http.NotFound(w, r) |
| 193 | }) |
| 194 | ts := httptest.NewServer(mux) |
| 195 | t.Cleanup(ts.Close) |
| 196 | |
| 197 | got, err := fetchSkill(ts.URL+"/prefix/index.json", "x") |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if string(got) != body { |
| 202 | t.Fatalf("fetched %q, want %q", got, body) |
| 203 | } |
| 204 | if !requestedRootPath { |
| 205 | t.Fatal("never hit the root-relative path") |
| 206 | } |
| 207 | } |
| 208 | |