v0.14.0 75d4151

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}
250
251func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
252	var m ZutfileManifest
253	m.Model.MinTier = "frontier"
254	if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
255		t.Fatal("unsupported min_tier was ignored")
256	}
257	m.Model.MinTier = ""
258	m.Model.Requires = []string{"audio"}
259	if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
260		t.Fatal("unsupported capability was ignored")
261	}
262}
263
264func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
265	makeTar := func(name string, size int64) []byte {
266		var buf bytes.Buffer
267		tw := tar.NewWriter(&buf)
268		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
269			t.Fatal(err)
270		}
271		_ = tw.Close()
272		return buf.Bytes()
273	}
274	if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
275		t.Fatal("path traversal accepted")
276	}
277	if err := untar(bytes.NewReader(makeTar("large", maxZutfileEntrySize+1)), t.TempDir()); err == nil {
278		t.Fatal("oversized entry accepted")
279	}
280}
281
282func TestParseGitHubAgentURL(t *testing.T) {
283	tests := []struct {
284		input                 string
285		owner, repo, ref, dir string
286	}{
287		{"https://github.com/acme/agents/reviewer", "acme", "agents", "HEAD", "reviewer"},
288		{"https://github.com/acme/agents/tree/main/reviewer", "acme", "agents", "main", "reviewer"},
289		{"https://github.com/acme/agents/reviewers/go", "acme", "agents", "HEAD", "reviewers/go"},
290		{"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
291	}
292	for _, tt := range tests {
293		u, err := url.Parse(tt.input)
294		if err != nil {
295			t.Fatal(err)
296		}
297		owner, repo, ref, dir, err := parseGitHubAgentURL(u)
298		if err != nil {
299			t.Fatalf("parse %s: %v", tt.input, err)
300		}
301		if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
302			t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
303		}
304	}
305}
306
307func TestLoadRemoteZutfileDownloadsTemporaryGitHubArchive(t *testing.T) {
308	var archive bytes.Buffer
309	gz := gzip.NewWriter(&archive)
310	tw := tar.NewWriter(gz)
311	files := map[string]string{
312		"agents-main/zut-maintenance/manifest.json": `{"zutfile":1,"name":"zut-maintenance"}`,
313		"agents-main/zut-maintenance/AGENT.md":      "Maintain zut.",
314	}
315	for name, content := range files {
316		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
317			t.Fatal(err)
318		}
319		if _, err := tw.Write([]byte(content)); err != nil {
320			t.Fatal(err)
321		}
322	}
323	if err := tw.Close(); err != nil {
324		t.Fatal(err)
325	}
326	if err := gz.Close(); err != nil {
327		t.Fatal(err)
328	}
329
330	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
331		w.Header().Set("Content-Type", "application/gzip")
332		_, _ = w.Write(archive.Bytes())
333	}))
334	defer server.Close()
335	oldArchiveURL := githubArchiveURL
336	githubArchiveURL = func(_, _, _ string) string { return server.URL }
337	t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
338
339	u, _ := url.Parse("https://github.com/acme/agents/zut-maintenance")
340	zf, cleanup, err := loadRemoteZutfile(u)
341	if err != nil {
342		t.Fatal(err)
343	}
344	if zf.Manifest.Name != "zut-maintenance" || !zf.Temp {
345		t.Fatalf("unexpected zutfile: %+v", zf)
346	}
347	root := filepath.Dir(filepath.Dir(zf.Dir))
348	cleanup()
349	if _, err := os.Stat(root); !os.IsNotExist(err) {
350		t.Fatalf("temporary checkout was not removed: %v", err)
351	}
352}
353
354func TestFormatZutfileConsentUsesThemeColors(t *testing.T) {
355	th := tui.Theme{
356		FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3),
357		Assistant: tui.Color256(4), Warning: tui.Color256(5),
358	}
359	zf := zutfileLoaded{}
360	zf.Manifest.Name = "reviewer"
361	zf.Manifest.Version = "1.2.3"
362	perms := tools.PermissionSet{}
363	perms.FS.Read = []string{"/workspace"}
364	perms.Bash.Mode = "ask"
365
366	got := formatZutfileConsent(zf, perms, th, true)
367	for _, want := range []string{
368		th.FGColor(th.Assistant, "Agent"),
369		th.FGColor(th.Accent, "reviewer@1.2.3"),
370		th.FGColor(th.Muted, "  fs read: "),
371		th.FGColor(th.FG, "/workspace"),
372		th.FGColor(th.Warning, "ask"),
373		th.FGColor(th.Assistant, "Allow?"),
374		th.FGColor(th.Muted, "[y/n]"),
375	} {
376		if !strings.Contains(got, want) {
377			t.Fatalf("themed consent missing %q:\n%s", want, got)
378		}
379	}
380}
381
382func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
383	got := permissionSummary(tools.PermissionSet{})
384	if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
385		t.Fatalf("summary did not show denied scopes:\n%s", got)
386	}
387}
388
389func TestPeelLeadingYes(t *testing.T) {
390	yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"})
391	if !yes {
392		t.Fatal("expected yes")
393	}
394	if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" {
395		t.Fatalf("rest = %v", rest)
396	}
397	yes, rest = peelLeadingYes([]string{"agent", "-y"})
398	if yes || len(rest) != 2 {
399		t.Fatalf("yes=%v rest=%v", yes, rest)
400	}
401}
402
403func TestConsentZutfileAutoYesWritesReceipt(t *testing.T) {
404	home := t.TempDir()
405	t.Setenv("ZUT_HOME", home)
406	zf := zutfileLoaded{Digest: "abc123", Manifest: ZutfileManifest{Name: "demo", Version: "1.0.0"}}
407	var perms tools.PermissionSet
408	perms.Bash.Mode = "none"
409	allowed, err := consentZutfile(zf, perms, true)
410	if err != nil || !allowed {
411		t.Fatalf("auto yes: allowed=%v err=%v", allowed, err)
412	}
413	path := filepath.Join(home, "agents", "demo", "consents", "abc123.json")
414	if _, err := os.Stat(path); err != nil {
415		t.Fatalf("consent receipt missing: %v", err)
416	}
417	// Second call without -y should use the receipt.
418	allowed, err = consentZutfile(zf, perms, false)
419	if err != nil || !allowed {
420		t.Fatalf("cached consent: allowed=%v err=%v", allowed, err)
421	}
422}
423
424func TestConsentZutfileAutoYesAskDoesNotCache(t *testing.T) {
425	home := t.TempDir()
426	t.Setenv("ZUT_HOME", home)
427	zf := zutfileLoaded{Digest: "askdig", Manifest: ZutfileManifest{Name: "ask-agent", Version: "1.0.0"}}
428	var perms tools.PermissionSet
429	perms.Bash.Mode = "ask"
430	if _, err := consentZutfile(zf, perms, true); err != nil {
431		t.Fatal(err)
432	}
433	path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json")
434	if _, err := os.Stat(path); !os.IsNotExist(err) {
435		t.Fatalf("ask mode should not cache consent, err=%v", err)
436	}
437}

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 477

↑↓ moveenter openesc close