v0.11.3 6c26357

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

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