v0.3.30 2501d48

zotfile_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/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 TestLoadZotfileAllowsMissingAgentMD(t *testing.T) {
97	dir := t.TempDir()
98	if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil {
99		t.Fatal(err)
100	}
101	zf, cleanup, err := loadZotfile(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 TestValidateZotfileDirRejectsAgentMDDirectory(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 := validateZotfileDir(dir); err == nil || !strings.Contains(err.Error(), "regular file") {
119		t.Fatalf("unexpected error: %v", err)
120	}
121}
122
123func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
124	for _, field := range []string{
125		`"net":{"allow":["example.com"]}`,
126		`"env":{"read":["HOME"]}`,
127	} {
128		dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`)
129		if _, _, err := loadZotfile(dir); err == nil {
130			t.Fatalf("manifest with %s was accepted", field)
131		}
132	}
133}
134
135func TestLoadZotfileBashModes(t *testing.T) {
136	for _, mode := range []string{"none", "ask", "allowlist"} {
137		manifest := `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"` + mode + `"}}}`
138		if mode == "allowlist" {
139			manifest = `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"allowlist","allow":["git"]}}}`
140		}
141		dir := writeTestZotfile(t, manifest)
142		if _, _, err := loadZotfile(dir); err != nil {
143			t.Fatalf("mode %q rejected: %v", mode, err)
144		}
145	}
146	dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"all"}}}`)
147	if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") {
148		t.Fatalf("unexpected error for all mode: %v", err)
149	}
150	dir = writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"everything"}}}`)
151	if _, _, err := loadZotfile(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 TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
157	for _, name := range []string{"...", "Name", "two words", "a/b"} {
158		dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
159		if _, _, err := loadZotfile(dir); err == nil {
160			t.Fatalf("unsafe manifest name %q was accepted", name)
161		}
162	}
163}
164
165func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(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.zot", []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.zot"},
181		{"remote-agent", officialZotfileCollection + "/remote-agent"},
182		{"missing.zot", "missing.zot"},
183		{"agents/remote-agent", officialZotfileCollection + "/remote-agent"},
184		{`agents\remote-agent`, officialZotfileCollection + "/remote-agent"},
185		{"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
186		{"./missing-agent", "./missing-agent"},
187		{"Remote-Agent", "Remote-Agent"},
188		{"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
189	}
190	for _, tt := range tests {
191		got, err := resolveZotfileRef(tt.ref)
192		if err != nil {
193			t.Fatalf("resolve %q: %v", tt.ref, err)
194		}
195		if got != tt.want {
196			t.Errorf("resolve %q = %q, want %q", tt.ref, got, tt.want)
197		}
198	}
199}
200
201func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) {
202	dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`)
203	ext := filepath.Join(dir, "extensions", "bad")
204	if err := os.MkdirAll(ext, 0o700); err != nil {
205		t.Fatal(err)
206	}
207	if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
208		t.Fatal(err)
209	}
210	if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
211		t.Fatalf("unexpected error: %v", err)
212	}
213}
214
215func TestCheckZotfileMinVersion(t *testing.T) {
216	var zf zotfileLoaded
217	zf.Manifest.Runtime.MinZot = "0.3.0"
218	if err := checkZotfileRequirements(zf, "0.2.75"); err == nil {
219		t.Fatal("old zot version accepted")
220	}
221	if err := checkZotfileRequirements(zf, "0.3.0"); err != nil {
222		t.Fatalf("minimum version rejected: %v", err)
223	}
224}
225
226func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
227	var m ZotfileManifest
228	m.Model.MinTier = "frontier"
229	if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
230		t.Fatal("unsupported min_tier was ignored")
231	}
232	m.Model.MinTier = ""
233	m.Model.Requires = []string{"audio"}
234	if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
235		t.Fatal("unsupported capability was ignored")
236	}
237}
238
239func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
240	makeTar := func(name string, size int64) []byte {
241		var buf bytes.Buffer
242		tw := tar.NewWriter(&buf)
243		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
244			t.Fatal(err)
245		}
246		_ = tw.Close()
247		return buf.Bytes()
248	}
249	if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
250		t.Fatal("path traversal accepted")
251	}
252	if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil {
253		t.Fatal("oversized entry accepted")
254	}
255}
256
257func TestParseGitHubAgentURL(t *testing.T) {
258	tests := []struct {
259		input                 string
260		owner, repo, ref, dir string
261	}{
262		{"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"},
263		{"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"},
264		{"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
265	}
266	for _, tt := range tests {
267		u, err := url.Parse(tt.input)
268		if err != nil {
269			t.Fatal(err)
270		}
271		owner, repo, ref, dir, err := parseGitHubAgentURL(u)
272		if err != nil {
273			t.Fatalf("parse %s: %v", tt.input, err)
274		}
275		if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
276			t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
277		}
278	}
279}
280
281func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
282	var archive bytes.Buffer
283	gz := gzip.NewWriter(&archive)
284	tw := tar.NewWriter(gz)
285	files := map[string]string{
286		"agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`,
287		"agents-main/zot-maintenance/AGENT.md":      "Maintain zot.",
288	}
289	for name, content := range files {
290		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
291			t.Fatal(err)
292		}
293		if _, err := tw.Write([]byte(content)); err != nil {
294			t.Fatal(err)
295		}
296	}
297	if err := tw.Close(); err != nil {
298		t.Fatal(err)
299	}
300	if err := gz.Close(); err != nil {
301		t.Fatal(err)
302	}
303
304	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305		w.Header().Set("Content-Type", "application/gzip")
306		_, _ = w.Write(archive.Bytes())
307	}))
308	defer server.Close()
309	oldArchiveURL := githubArchiveURL
310	githubArchiveURL = func(_, _, _ string) string { return server.URL }
311	t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
312
313	u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance")
314	zf, cleanup, err := loadRemoteZotfile(u)
315	if err != nil {
316		t.Fatal(err)
317	}
318	if zf.Manifest.Name != "zot-maintenance" || !zf.Temp {
319		t.Fatalf("unexpected zotfile: %+v", zf)
320	}
321	root := filepath.Dir(filepath.Dir(zf.Dir))
322	cleanup()
323	if _, err := os.Stat(root); !os.IsNotExist(err) {
324		t.Fatalf("temporary checkout was not removed: %v", err)
325	}
326}
327
328func TestFormatZotfileConsentUsesThemeColors(t *testing.T) {
329	th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5}
330	zf := zotfileLoaded{}
331	zf.Manifest.Name = "reviewer"
332	zf.Manifest.Version = "1.2.3"
333	perms := tools.PermissionSet{}
334	perms.FS.Read = []string{"/workspace"}
335	perms.Bash.Mode = "ask"
336
337	got := formatZotfileConsent(zf, perms, th, true)
338	for _, want := range []string{
339		th.FG256(th.Assistant, "Agent"),
340		th.FG256(th.Accent, "reviewer@1.2.3"),
341		th.FG256(th.Muted, "  fs read: "),
342		th.FG256(th.FG, "/workspace"),
343		th.FG256(th.Warning, "ask"),
344		th.FG256(th.Assistant, "Allow?"),
345		th.FG256(th.Muted, "[y/n]"),
346	} {
347		if !strings.Contains(got, want) {
348			t.Fatalf("themed consent missing %q:\n%s", want, got)
349		}
350	}
351}
352
353func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
354	got := permissionSummary(tools.PermissionSet{})
355	if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
356		t.Fatalf("summary did not show denied scopes:\n%s", got)
357	}
358}
359
360func TestPeelLeadingYes(t *testing.T) {
361	yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"})
362	if !yes {
363		t.Fatal("expected yes")
364	}
365	if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" {
366		t.Fatalf("rest = %v", rest)
367	}
368	yes, rest = peelLeadingYes([]string{"agent", "-y"})
369	if yes || len(rest) != 2 {
370		t.Fatalf("yes=%v rest=%v", yes, rest)
371	}
372}
373
374func TestConsentZotfileAutoYesWritesReceipt(t *testing.T) {
375	home := t.TempDir()
376	t.Setenv("ZOT_HOME", home)
377	zf := zotfileLoaded{Digest: "abc123", Manifest: ZotfileManifest{Name: "demo", Version: "1.0.0"}}
378	var perms tools.PermissionSet
379	perms.Bash.Mode = "none"
380	allowed, err := consentZotfile(zf, perms, true)
381	if err != nil || !allowed {
382		t.Fatalf("auto yes: allowed=%v err=%v", allowed, err)
383	}
384	path := filepath.Join(home, "agents", "demo", "consents", "abc123.json")
385	if _, err := os.Stat(path); err != nil {
386		t.Fatalf("consent receipt missing: %v", err)
387	}
388	// Second call without -y should use the receipt.
389	allowed, err = consentZotfile(zf, perms, false)
390	if err != nil || !allowed {
391		t.Fatalf("cached consent: allowed=%v err=%v", allowed, err)
392	}
393}
394
395func TestConsentZotfileAutoYesAskDoesNotCache(t *testing.T) {
396	home := t.TempDir()
397	t.Setenv("ZOT_HOME", home)
398	zf := zotfileLoaded{Digest: "askdig", Manifest: ZotfileManifest{Name: "ask-agent", Version: "1.0.0"}}
399	var perms tools.PermissionSet
400	perms.Bash.Mode = "ask"
401	if _, err := consentZotfile(zf, perms, true); err != nil {
402		t.Fatal(err)
403	}
404	path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json")
405	if _, err := os.Stat(path); !os.IsNotExist(err) {
406		t.Fatalf("ask mode should not cache consent, err=%v", err)
407	}
408}

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