v0.50.1 a637010

zutfile_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close