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/bnema/zut/packages/agent/tools"
18 "github.com/bnema/zut/packages/tui"
19)
20
21func TestRunZutfileOrchestrationRejectsPackagedAgentBeforeSideEffects(t *testing.T) {
22 home := t.TempDir()
23 t.Setenv("ZUT_HOME", home)
24 // A nonexistent reference proves validation happens before local loading,
25 // remote fetching, unpacking, data-directory creation, or consent.
26 missingPackage := filepath.Join(t.TempDir(), "missing.zut")
27
28 handled, err := runZutfileCommand([]string{"run", missingPackage, "--print", "--orchestrate", "task"}, "test")
29 if !handled {
30 t.Fatal("run command was not handled")
31 }
32 if err == nil || !strings.Contains(err.Error(), "packaged agents") {
33 t.Fatalf("run error = %v, want packaged-agent validation error", err)
34 }
35 entries, readErr := os.ReadDir(home)
36 if readErr != nil {
37 t.Fatal(readErr)
38 }
39 if len(entries) != 0 {
40 t.Fatalf("packaged-agent rejection created state: %v", entries)
41 }
42}
43
44func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
45 tests := []struct {
46 name string
47 input []byte
48 want byte
49 }{
50 {name: "lowercase yes", input: []byte{'y', '\r'}, want: 'y'},
51 {name: "uppercase yes", input: []byte{'Y', '\n'}, want: 'y'},
52 {name: "lowercase no", input: []byte{'n', '\r'}, want: 'n'},
53 {name: "uppercase no", input: []byte{'N', '\n'}, want: 'n'},
54 {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y', '\r'), want: 'y'},
55 {name: "keep first valid answer", input: []byte{'n', 'y', '\r'}, want: 'n'},
56 {name: "backspace changes answer", input: []byte{'y', 0x7f, 'n', '\r'}, want: 'n'},
57 {name: "ctrl h changes answer", input: []byte{'n', '\b', 'y', '\r'}, want: 'y'},
58 {name: "backspace without answer is ignored", input: []byte{0x7f, 'y', '\r'}, want: 'y'},
59 }
60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {
62 var out bytes.Buffer
63 got, err := readZutfileConsentKey(bytes.NewReader(tt.input), func(answer byte) {
64 if answer == 0 {
65 out.Truncate(out.Len() - 1)
66 return
67 }
68 out.WriteByte(answer)
69 })
70 if err != nil {
71 t.Fatal(err)
72 }
73 if got != tt.want {
74 t.Fatalf("answer = %q, want %q", got, tt.want)
75 }
76 if out.String() != string(tt.want) {
77 t.Fatalf("echo = %q, want %q", out.String(), tt.want)
78 }
79 })
80 }
81}
82
83func TestReadZutfileConsentKeyWaitsForEnter(t *testing.T) {
84 var out bytes.Buffer
85 if _, err := readZutfileConsentKey(strings.NewReader("y"), func(answer byte) {
86 out.WriteByte(answer)
87 }); !errors.Is(err, io.EOF) {
88 t.Fatalf("error = %v, want EOF before Enter", err)
89 }
90 if out.String() != "y" {
91 t.Fatalf("echo = %q, want y", out.String())
92 }
93}
94
95func TestReadZutfileConsentKeyInterruptsOnCtrlC(t *testing.T) {
96 if _, err := readZutfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'}), nil); !errors.Is(err, errZutfileConsentInterrupted) {
97 t.Fatalf("error = %v, want interrupted", err)
98 }
99}
100
101func TestReadZutfileConsentKeyReportsInputError(t *testing.T) {
102 if _, err := readZutfileConsentKey(strings.NewReader("xxx"), nil); !errors.Is(err, io.EOF) {
103 t.Fatalf("error = %v, want EOF", err)
104 }
105}
106
107func writeTestZutfile(t *testing.T, manifest string) string {
108 t.Helper()
109 dir := t.TempDir()
110 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
111 t.Fatal(err)
112 }
113 if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
114 t.Fatal(err)
115 }
116 return dir
117}
118
119func TestLoadZutfileAllowsMissingAgentMD(t *testing.T) {
120 dir := t.TempDir()
121 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zutfile":1,"name":"test"}`), 0o600); err != nil {
122 t.Fatal(err)
123 }
124 zf, cleanup, err := loadZutfile(dir)
125 if cleanup != nil {
126 defer cleanup()
127 }
128 if err != nil {
129 t.Fatalf("load without AGENT.md: %v", err)
130 }
131 if zf.Manifest.Name != "test" {
132 t.Fatalf("name = %q, want test", zf.Manifest.Name)
133 }
134}
135
136func TestValidateZutfileDirRejectsAgentMDDirectory(t *testing.T) {
137 dir := t.TempDir()
138 if err := os.Mkdir(filepath.Join(dir, "AGENT.md"), 0o700); err != nil {
139 t.Fatal(err)
140 }
141 if err := validateZutfileDir(dir); err == nil || !strings.Contains(err.Error(), "regular file") {
142 t.Fatalf("unexpected error: %v", err)
143 }
144}
145
146func TestLoadZutfileRejectsUnenforcedPermissions(t *testing.T) {
147 for _, field := range []string{
148 `"net":{"allow":["example.com"]}`,
149 `"env":{"read":["HOME"]}`,
150 } {
151 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{`+field+`}}`)
152 if _, _, err := loadZutfile(dir); err == nil {
153 t.Fatalf("manifest with %s was accepted", field)
154 }
155 }
156}
157
158func TestLoadZutfileBashModes(t *testing.T) {
159 for _, mode := range []string{"none", "ask", "allowlist"} {
160 manifest := `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"` + mode + `"}}}`
161 if mode == "allowlist" {
162 manifest = `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"allowlist","allow":["git"]}}}`
163 }
164 dir := writeTestZutfile(t, manifest)
165 if _, _, err := loadZutfile(dir); err != nil {
166 t.Fatalf("mode %q rejected: %v", mode, err)
167 }
168 }
169 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"all"}}}`)
170 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") {
171 t.Fatalf("unexpected error for all mode: %v", err)
172 }
173 dir = writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"everything"}}}`)
174 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") {
175 t.Fatalf("unexpected error for unknown mode: %v", err)
176 }
177}
178
179func TestLoadZutfileRejectsUnsafeOrCollidingNames(t *testing.T) {
180 for _, name := range []string{"...", "Name", "two words", "a/b"} {
181 dir := writeTestZutfile(t, `{"zutfile":1,"name":"`+name+`"}`)
182 if _, _, err := loadZutfile(dir); err == nil {
183 t.Fatalf("unsafe manifest name %q was accepted", name)
184 }
185 }
186}
187
188func TestResolveZutfileRefUsesLocalFirstThenGitHubShorthand(t *testing.T) {
189 dir := t.TempDir()
190 t.Chdir(dir)
191 if err := os.Mkdir("local-agent", 0o700); err != nil {
192 t.Fatal(err)
193 }
194 if err := os.WriteFile("packed-agent.zut", []byte("test"), 0o600); err != nil {
195 t.Fatal(err)
196 }
197
198 tests := []struct {
199 ref string
200 want string
201 }{
202 {"local-agent", "local-agent"},
203 {"packed-agent", "packed-agent.zut"},
204 {"remote-agent", "remote-agent"},
205 {"missing.zut", "missing.zut"},
206 {"agents/remote-agent", "https://github.com/agents/remote-agent"},
207 {`agents\remote-agent`, "https://github.com/agents/remote-agent"},
208 {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
209 {"acme/agents/reviewer", "https://github.com/acme/agents/reviewer"},
210 {`acme\agents\reviewers\go`, "https://github.com/acme/agents/reviewers/go"},
211 {"./missing-agent", "./missing-agent"},
212 {"Remote-Agent", "Remote-Agent"},
213 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
214 }
215 for _, tt := range tests {
216 got, err := resolveZutfileRef(tt.ref)
217 if err != nil {
218 t.Fatalf("resolve %q: %v", tt.ref, err)
219 }
220 if got != tt.want {
221 t.Errorf("resolve %q = %q, want %q", tt.ref, got, tt.want)
222 }
223 }
224}
225
226func TestLoadZutfileRejectsBundledExecutableExtension(t *testing.T) {
227 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test"}`)
228 ext := filepath.Join(dir, "extensions", "bad")
229 if err := os.MkdirAll(ext, 0o700); err != nil {
230 t.Fatal(err)
231 }
232 if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
233 t.Fatal(err)
234 }
235 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
236 t.Fatalf("unexpected error: %v", err)
237 }
238}
239
240func TestCheckZutfileMinVersion(t *testing.T) {
241 var zf zutfileLoaded
242 zf.Manifest.Runtime.MinZut = "0.3.0"
243 if err := checkZutfileRequirements(zf, "0.2.75"); err == nil {
244 t.Fatal("old zut version accepted")
245 }
246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
247 t.Fatalf("minimum version rejected: %v", err)
248 }
249 if err := checkZutfileRequirements(zf, "not-a-version"); err == nil {
250 t.Fatal("invalid zut version accepted")
251 }
252
253 zf.Manifest.Runtime.MinZut = "not-a-version"
254 if err := checkZutfileRequirements(zf, "0.3.0"); err == nil {
255 t.Fatal("invalid min_zut requirement accepted")
256 }
257}
258
259func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
260 var m ZutfileManifest
261 m.Model.MinTier = "frontier"
262 if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
263 t.Fatal("unsupported min_tier was ignored")
264 }
265 m.Model.MinTier = ""
266 m.Model.Requires = []string{"audio"}
267 if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
268 t.Fatal("unsupported capability was ignored")
269 }
270}
271
272func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
273 makeTar := func(name string, size int64) []byte {
274 var buf bytes.Buffer
275 tw := tar.NewWriter(&buf)
276 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
277 t.Fatal(err)
278 }
279 _ = tw.Close()
280 return buf.Bytes()
281 }
282 if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
283 t.Fatal("path traversal accepted")
284 }
285 if err := untar(bytes.NewReader(makeTar("large", maxZutfileEntrySize+1)), t.TempDir()); err == nil {
286 t.Fatal("oversized entry accepted")
287 }
288}
289
290func TestParseGitHubAgentURL(t *testing.T) {
291 tests := []struct {
292 input string
293 owner, repo, ref, dir string
294 }{
295 {"https://github.com/acme/agents/reviewer", "acme", "agents", "HEAD", "reviewer"},
296 {"https://github.com/acme/agents/tree/main/reviewer", "acme", "agents", "main", "reviewer"},
297 {"https://github.com/acme/agents/reviewers/go", "acme", "agents", "HEAD", "reviewers/go"},
298 {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
299 }
300 for _, tt := range tests {
301 u, err := url.Parse(tt.input)
302 if err != nil {
303 t.Fatal(err)
304 }
305 owner, repo, ref, dir, err := parseGitHubAgentURL(u)
306 if err != nil {
307 t.Fatalf("parse %s: %v", tt.input, err)
308 }
309 if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
310 t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
311 }
312 }
313}
314
315func TestLoadRemoteZutfileDownloadsTemporaryGitHubArchive(t *testing.T) {
316 var archive bytes.Buffer
317 gz := gzip.NewWriter(&archive)
318 tw := tar.NewWriter(gz)
319 files := map[string]string{
320 "agents-main/zut-maintenance/manifest.json": `{"zutfile":1,"name":"zut-maintenance"}`,
321 "agents-main/zut-maintenance/AGENT.md": "Maintain zut.",
322 }
323 for name, content := range files {
324 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
325 t.Fatal(err)
326 }
327 if _, err := tw.Write([]byte(content)); err != nil {
328 t.Fatal(err)
329 }
330 }
331 if err := tw.Close(); err != nil {
332 t.Fatal(err)
333 }
334 if err := gz.Close(); err != nil {
335 t.Fatal(err)
336 }
337
338 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
339 w.Header().Set("Content-Type", "application/gzip")
340 _, _ = w.Write(archive.Bytes())
341 }))
342 defer server.Close()
343 oldArchiveURL := githubArchiveURL
344 githubArchiveURL = func(_, _, _ string) string { return server.URL }
345 t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
346
347 u, _ := url.Parse("https://github.com/acme/agents/zut-maintenance")
348 zf, cleanup, err := loadRemoteZutfile(u)
349 if err != nil {
350 t.Fatal(err)
351 }
352 if zf.Manifest.Name != "zut-maintenance" || !zf.Temp {
353 t.Fatalf("unexpected zutfile: %+v", zf)
354 }
355 root := filepath.Dir(filepath.Dir(zf.Dir))
356 cleanup()
357 if _, err := os.Stat(root); !os.IsNotExist(err) {
358 t.Fatalf("temporary checkout was not removed: %v", err)
359 }
360}
361
362func TestFormatZutfileConsentUsesThemeColors(t *testing.T) {
363 th := tui.Theme{
364 FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3),
365 Assistant: tui.Color256(4), Warning: tui.Color256(5),
366 }
367 zf := zutfileLoaded{}
368 zf.Manifest.Name = "reviewer"
369 zf.Manifest.Version = "1.2.3"
370 perms := tools.PermissionSet{}
371 perms.FS.Read = []string{"/workspace"}
372 perms.Bash.Mode = "ask"
373
374 got := formatZutfileConsent(zf, perms, th, true)
375 for _, want := range []string{
376 th.FGColor(th.Assistant, "Agent"),
377 th.FGColor(th.Accent, "reviewer@1.2.3"),
378 th.FGColor(th.Muted, " fs read: "),
379 th.FGColor(th.FG, "/workspace"),
380 th.FGColor(th.Warning, "ask"),
381 th.FGColor(th.Assistant, "Allow?"),
382 th.FGColor(th.Muted, "[y/n]"),
383 } {
384 if !strings.Contains(got, want) {
385 t.Fatalf("themed consent missing %q:\n%s", want, got)
386 }
387 }
388}
389
390func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
391 got := permissionSummary(tools.PermissionSet{})
392 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
393 t.Fatalf("summary did not show denied scopes:\n%s", got)
394 }
395}
396
397func TestPeelLeadingYes(t *testing.T) {
398 yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"})
399 if !yes {
400 t.Fatal("expected yes")
401 }
402 if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" {
403 t.Fatalf("rest = %v", rest)
404 }
405 yes, rest = peelLeadingYes([]string{"agent", "-y"})
406 if yes || len(rest) != 2 {
407 t.Fatalf("yes=%v rest=%v", yes, rest)
408 }
409}
410
411func TestConsentZutfileAutoYesWritesReceipt(t *testing.T) {
412 home := t.TempDir()
413 t.Setenv("ZUT_HOME", home)
414 zf := zutfileLoaded{Digest: "abc123", Manifest: ZutfileManifest{Name: "demo", Version: "1.0.0"}}
415 var perms tools.PermissionSet
416 perms.Bash.Mode = "none"
417 allowed, err := consentZutfile(zf, perms, true)
418 if err != nil || !allowed {
419 t.Fatalf("auto yes: allowed=%v err=%v", allowed, err)
420 }
421 path := filepath.Join(home, "agents", "demo", "consents", "abc123.json")
422 if _, err := os.Stat(path); err != nil {
423 t.Fatalf("consent receipt missing: %v", err)
424 }
425 // Second call without -y should use the receipt.
426 allowed, err = consentZutfile(zf, perms, false)
427 if err != nil || !allowed {
428 t.Fatalf("cached consent: allowed=%v err=%v", allowed, err)
429 }
430}
431
432func TestConsentZutfileAutoYesAskDoesNotCache(t *testing.T) {
433 home := t.TempDir()
434 t.Setenv("ZUT_HOME", home)
435 zf := zutfileLoaded{Digest: "askdig", Manifest: ZutfileManifest{Name: "ask-agent", Version: "1.0.0"}}
436 var perms tools.PermissionSet
437 perms.Bash.Mode = "ask"
438 if _, err := consentZutfile(zf, perms, true); err != nil {
439 t.Fatal(err)
440 }
441 path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json")
442 if _, err := os.Stat(path); !os.IsNotExist(err) {
443 t.Fatalf("ask mode should not cache consent, err=%v", err)
444 }
445}