1package agent
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "github.com/patriceckhart/zot/packages/agent/tools"
18 "github.com/patriceckhart/zot/packages/tui"
19)
20
21func TestReadZotfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
22 tests := []struct {
23 name string
24 input []byte
25 want byte
26 }{
27 {name: "lowercase yes", input: []byte{'y', '\r'}, want: 'y'},
28 {name: "uppercase yes", input: []byte{'Y', '\n'}, want: 'y'},
29 {name: "lowercase no", input: []byte{'n', '\r'}, want: 'n'},
30 {name: "uppercase no", input: []byte{'N', '\n'}, want: 'n'},
31 {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y', '\r'), want: 'y'},
32 {name: "keep first valid answer", input: []byte{'n', 'y', '\r'}, want: 'n'},
33 {name: "backspace changes answer", input: []byte{'y', 0x7f, 'n', '\r'}, want: 'n'},
34 {name: "ctrl h changes answer", input: []byte{'n', '\b', 'y', '\r'}, want: 'y'},
35 {name: "backspace without answer is ignored", input: []byte{0x7f, 'y', '\r'}, want: 'y'},
36 }
37 for _, tt := range tests {
38 t.Run(tt.name, func(t *testing.T) {
39 var out bytes.Buffer
40 got, err := readZotfileConsentKey(bytes.NewReader(tt.input), func(answer byte) {
41 if answer == 0 {
42 out.Truncate(out.Len() - 1)
43 return
44 }
45 out.WriteByte(answer)
46 })
47 if err != nil {
48 t.Fatal(err)
49 }
50 if got != tt.want {
51 t.Fatalf("answer = %q, want %q", got, tt.want)
52 }
53 if out.String() != string(tt.want) {
54 t.Fatalf("echo = %q, want %q", out.String(), tt.want)
55 }
56 })
57 }
58}
59
60func TestReadZotfileConsentKeyWaitsForEnter(t *testing.T) {
61 var out bytes.Buffer
62 if _, err := readZotfileConsentKey(strings.NewReader("y"), func(answer byte) {
63 out.WriteByte(answer)
64 }); !errors.Is(err, io.EOF) {
65 t.Fatalf("error = %v, want EOF before Enter", err)
66 }
67 if out.String() != "y" {
68 t.Fatalf("echo = %q, want y", out.String())
69 }
70}
71
72func TestReadZotfileConsentKeyInterruptsOnCtrlC(t *testing.T) {
73 if _, err := readZotfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'}), nil); !errors.Is(err, errZotfileConsentInterrupted) {
74 t.Fatalf("error = %v, want interrupted", err)
75 }
76}
77
78func TestReadZotfileConsentKeyReportsInputError(t *testing.T) {
79 if _, err := readZotfileConsentKey(strings.NewReader("xxx"), nil); !errors.Is(err, io.EOF) {
80 t.Fatalf("error = %v, want EOF", err)
81 }
82}
83
84func writeTestZotfile(t *testing.T, manifest string) string {
85 t.Helper()
86 dir := t.TempDir()
87 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
88 t.Fatal(err)
89 }
90 if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
91 t.Fatal(err)
92 }
93 return dir
94}
95
96func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
97 for _, field := range []string{
98 `"net":{"allow":["example.com"]}`,
99 `"env":{"read":["HOME"]}`,
100 } {
101 dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`)
102 if _, _, err := loadZotfile(dir); err == nil {
103 t.Fatalf("manifest with %s was accepted", field)
104 }
105 }
106}
107
108func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
109 for _, name := range []string{"...", "Name", "two words", "a/b"} {
110 dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
111 if _, _, err := loadZotfile(dir); err == nil {
112 t.Fatalf("unsafe manifest name %q was accepted", name)
113 }
114 }
115}
116
117func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) {
118 dir := t.TempDir()
119 t.Chdir(dir)
120 if err := os.Mkdir("local-agent", 0o700); err != nil {
121 t.Fatal(err)
122 }
123 if err := os.WriteFile("packed-agent.zot", []byte("test"), 0o600); err != nil {
124 t.Fatal(err)
125 }
126
127 tests := []struct {
128 ref string
129 want string
130 }{
131 {"local-agent", "local-agent"},
132 {"packed-agent", "packed-agent.zot"},
133 {"remote-agent", officialZotfileCollection + "/remote-agent"},
134 {"missing.zot", "missing.zot"},
135 {"agents/remote-agent", "https://github.com/patriceckhart/agents/remote-agent"},
136 {`agents\remote-agent`, "https://github.com/patriceckhart/agents/remote-agent"},
137 {"./missing-agent", "./missing-agent"},
138 {"Remote-Agent", "Remote-Agent"},
139 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
140 }
141 for _, tt := range tests {
142 got, err := resolveZotfileRef(tt.ref)
143 if err != nil {
144 t.Fatalf("resolve %q: %v", tt.ref, err)
145 }
146 if got != tt.want {
147 t.Errorf("resolve %q = %q, want %q", tt.ref, got, tt.want)
148 }
149 }
150}
151
152func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) {
153 dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`)
154 ext := filepath.Join(dir, "extensions", "bad")
155 if err := os.MkdirAll(ext, 0o700); err != nil {
156 t.Fatal(err)
157 }
158 if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
159 t.Fatal(err)
160 }
161 if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
162 t.Fatalf("unexpected error: %v", err)
163 }
164}
165
166func TestCheckZotfileMinVersion(t *testing.T) {
167 var zf zotfileLoaded
168 zf.Manifest.Runtime.MinZot = "0.3.0"
169 if err := checkZotfileRequirements(zf, "0.2.75"); err == nil {
170 t.Fatal("old zot version accepted")
171 }
172 if err := checkZotfileRequirements(zf, "0.3.0"); err != nil {
173 t.Fatalf("minimum version rejected: %v", err)
174 }
175}
176
177func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
178 var m ZotfileManifest
179 m.Model.MinTier = "frontier"
180 if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
181 t.Fatal("unsupported min_tier was ignored")
182 }
183 m.Model.MinTier = ""
184 m.Model.Requires = []string{"audio"}
185 if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
186 t.Fatal("unsupported capability was ignored")
187 }
188}
189
190func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
191 makeTar := func(name string, size int64) []byte {
192 var buf bytes.Buffer
193 tw := tar.NewWriter(&buf)
194 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
195 t.Fatal(err)
196 }
197 _ = tw.Close()
198 return buf.Bytes()
199 }
200 if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
201 t.Fatal("path traversal accepted")
202 }
203 if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil {
204 t.Fatal("oversized entry accepted")
205 }
206}
207
208func TestParseGitHubAgentURL(t *testing.T) {
209 tests := []struct {
210 input string
211 owner, repo, ref, dir string
212 }{
213 {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"},
214 {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"},
215 {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
216 }
217 for _, tt := range tests {
218 u, err := url.Parse(tt.input)
219 if err != nil {
220 t.Fatal(err)
221 }
222 owner, repo, ref, dir, err := parseGitHubAgentURL(u)
223 if err != nil {
224 t.Fatalf("parse %s: %v", tt.input, err)
225 }
226 if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
227 t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
228 }
229 }
230}
231
232func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
233 var archive bytes.Buffer
234 gz := gzip.NewWriter(&archive)
235 tw := tar.NewWriter(gz)
236 files := map[string]string{
237 "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`,
238 "agents-main/zot-maintenance/AGENT.md": "Maintain zot.",
239 }
240 for name, content := range files {
241 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
242 t.Fatal(err)
243 }
244 if _, err := tw.Write([]byte(content)); err != nil {
245 t.Fatal(err)
246 }
247 }
248 if err := tw.Close(); err != nil {
249 t.Fatal(err)
250 }
251 if err := gz.Close(); err != nil {
252 t.Fatal(err)
253 }
254
255 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
256 w.Header().Set("Content-Type", "application/gzip")
257 _, _ = w.Write(archive.Bytes())
258 }))
259 defer server.Close()
260 oldArchiveURL := githubArchiveURL
261 githubArchiveURL = func(_, _, _ string) string { return server.URL }
262 t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
263
264 u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance")
265 zf, cleanup, err := loadRemoteZotfile(u)
266 if err != nil {
267 t.Fatal(err)
268 }
269 if zf.Manifest.Name != "zot-maintenance" || !zf.Temp {
270 t.Fatalf("unexpected zotfile: %+v", zf)
271 }
272 root := filepath.Dir(filepath.Dir(zf.Dir))
273 cleanup()
274 if _, err := os.Stat(root); !os.IsNotExist(err) {
275 t.Fatalf("temporary checkout was not removed: %v", err)
276 }
277}
278
279func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
280 th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5}
281 zf := zotfileLoaded{}
282 zf.Manifest.Name = "reviewer"
283 zf.Manifest.Version = "1.2.3"
284 perms := tools.PermissionSet{}
285 perms.FS.Read = []string{"/workspace"}
286 perms.Bash.Mode = "ask"
287
288 got := formatZotfileConsent(zf, perms, th, true)
289 for _, want := range []string{
290 th.FG256(th.Assistant, "Agent"),
291 th.FG256(th.Accent, "reviewer@1.2.3"),
292 th.FG256(th.Muted, " fs read: "),
293 th.FG256(th.FG, "/workspace"),
294 th.FG256(th.Warning, "ask"),
295 th.FG256(th.Assistant, "Allow?"),
296 th.FG256(th.Muted, "[y/n]"),
297 } {
298 if !strings.Contains(got, want) {
299 t.Fatalf("themed consent missing %q:\n%s", want, got)
300 }
301 }
302}
303
304func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
305 got := permissionSummary(tools.PermissionSet{})
306 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
307 t.Fatalf("summary did not show denied scopes:\n%s", got)
308 }
309}