v0.3.29 f3d8eb6

zotfile.go RawHistoryFolder

1package agent
2
3import (
4	"archive/tar"
5	"bytes"
6	"compress/gzip"
7	"crypto/sha256"
8	"encoding/hex"
9	"encoding/json"
10	"errors"
11	"fmt"
12	"io"
13	"net/http"
14	"net/url"
15	"os"
16	"os/exec"
17	"path/filepath"
18	"runtime"
19	"sort"
20	"strings"
21	"time"
22
23	"github.com/klauspost/compress/zstd"
24	"github.com/patriceckhart/zot/packages/agent/tools"
25	"github.com/patriceckhart/zot/packages/provider"
26	"github.com/patriceckhart/zot/packages/provider/auth"
27	"github.com/patriceckhart/zot/packages/tui"
28	"golang.org/x/term"
29)
30
31type ZotfileManifest struct {
32	Zotfile     int    `json:"zotfile"`
33	Name        string `json:"name"`
34	Version     string `json:"version"`
35	Description string `json:"description"`
36	License     string `json:"license"`
37	Runtime     struct {
38		MinZot string `json:"min_zot"`
39	} `json:"runtime"`
40	Model struct {
41		Requires   []string `json:"requires"`
42		MinContext int      `json:"min_context"`
43		Preferred  []string `json:"preferred"`
44		MinTier    string   `json:"min_tier"`
45	} `json:"model"`
46	Permissions  tools.PermissionSet `json:"permissions"`
47	Requirements struct {
48		Bin []string `json:"bin"`
49		OS  []string `json:"os"`
50	} `json:"requirements"`
51	Entry struct {
52		Greeting      string  `json:"greeting"`
53		DefaultPrompt *string `json:"default_prompt"`
54	} `json:"entry"`
55	ReplaceSystemPrompt bool `json:"replace_system_prompt"`
56}
57
58type zotfileLoaded struct {
59	Dir      string
60	Temp     bool
61	Digest   string
62	Manifest ZotfileManifest
63}
64
65func runZotfileCommand(rawArgs []string, version string) (bool, error) {
66	if len(rawArgs) == 0 {
67		return false, nil
68	}
69	switch rawArgs[0] {
70	case "pack":
71		dir := "."
72		out := ""
73		if len(rawArgs) > 1 {
74			dir = rawArgs[1]
75		}
76		if len(rawArgs) > 2 {
77			out = rawArgs[2]
78		}
79		return true, zotPack(dir, out)
80	case "inspect":
81		if len(rawArgs) < 2 {
82			return true, fmt.Errorf("zot inspect requires a name, .zot file, directory, or GitHub URL")
83		}
84		return true, zotInspect(rawArgs[1])
85	case "verify":
86		if len(rawArgs) < 2 {
87			return true, fmt.Errorf("zot verify requires a name, .zot file, directory, or GitHub URL")
88		}
89		zf, cleanup, err := loadZotfile(rawArgs[1])
90		if cleanup != nil {
91			defer cleanup()
92		}
93		if err != nil {
94			return true, err
95		}
96		fmt.Printf("ok  digest sha256:%s\n", zf.Digest)
97		return true, nil
98	case "run":
99		if len(rawArgs) < 2 {
100			return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL")
101		}
102		ref := rawArgs[1]
103		rest := rawArgs[2:]
104		args, err := ParseArgs(rest)
105		if err != nil {
106			PrintHelp(version)
107			return true, err
108		}
109		return true, runLocalZotfile(ref, args, version)
110	default:
111		return false, nil
112	}
113}
114
115func runLocalZotfile(ref string, args Args, version string) error {
116	prepareRuntimeCatalog()
117	zf, cleanup, err := loadZotfile(ref)
118	if cleanup != nil {
119		defer cleanup()
120	}
121	if err != nil {
122		return err
123	}
124	if err := checkZotfileRequirements(zf, version); err != nil {
125		return err
126	}
127	agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
128	if err := os.MkdirAll(agentData, 0o755); err != nil {
129		return err
130	}
131	perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
132	allowed, err := consentZotfile(zf, perms)
133	if err != nil {
134		return err
135	}
136	if !allowed {
137		return nil
138	}
139	agentPath := filepath.Join(zf.Dir, "AGENT.md")
140	agentPrompt, err := os.ReadFile(agentPath)
141	if err != nil {
142		return fmt.Errorf("read AGENT.md: %w", err)
143	}
144	if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil {
145		return err
146	}
147	if zf.Manifest.ReplaceSystemPrompt {
148		args.SystemPrompt = strings.TrimSpace(string(agentPrompt))
149	} else {
150		args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt)))
151	}
152	if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
153		args.Prompt = *zf.Manifest.Entry.DefaultPrompt
154	}
155	if dirExists(filepath.Join(zf.Dir, "skills")) {
156		old := os.Getenv("ZOT_AGENT_SKILLS")
157		v := filepath.Join(zf.Dir, "skills")
158		if old != "" {
159			v += string(os.PathListSeparator) + old
160		}
161		_ = os.Setenv("ZOT_AGENT_SKILLS", v)
162		defer os.Setenv("ZOT_AGENT_SKILLS", old)
163	}
164	args.AgentName = zf.Manifest.Name
165	args.AgentDataDir = agentData
166	args.PermissionSet = &perms
167	return runWithArgs(args, version)
168}
169
170func zotInspect(ref string) error {
171	zf, cleanup, err := loadZotfile(ref)
172	if cleanup != nil {
173		defer cleanup()
174	}
175	if err != nil {
176		return err
177	}
178	m := zf.Manifest
179	fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
180	fmt.Println("\npermissions:")
181	fmt.Print(permissionSummary(m.Permissions))
182	fmt.Println("\nfiles:")
183	return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
184		if err != nil || path == zf.Dir {
185			return err
186		}
187		rel, _ := filepath.Rel(zf.Dir, path)
188		if d.IsDir() {
189			rel += "/"
190		}
191		fmt.Println("  " + filepath.ToSlash(rel))
192		return nil
193	})
194}
195
196func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
197	minCtx := m.Model.MinContext
198	requires := map[string]bool{}
199	for _, capability := range m.Model.Requires {
200		requires[strings.ToLower(strings.TrimSpace(capability))] = true
201	}
202	for capability := range requires {
203		if capability != "tools" && capability != "vision" && capability != "reasoning" {
204			return fmt.Errorf("unsupported model requirement %q", capability)
205		}
206	}
207	if m.Model.MinTier != "" {
208		return fmt.Errorf("model.min_tier is not supported by this zot version")
209	}
210	compatible := func(model provider.Model) bool {
211		if model.ContextWindow < minCtx {
212			return false
213		}
214		if requires["reasoning"] && !model.Reasoning {
215			return false
216		}
217		// Every model exposed by the current catalog supports text and tools.
218		// Vision support is not represented in Model yet, so fail closed.
219		return !requires["vision"]
220	}
221	if minCtx <= 0 && len(requires) == 0 {
222		return nil
223	}
224	if args.Model != "" {
225		model, err := provider.FindModel(args.Provider, args.Model)
226		if err != nil {
227			model, err = provider.FindModel("", args.Model)
228		}
229		if err != nil {
230			return nil
231		}
232		if !compatible(model) {
233			return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
234		}
235		return nil
236	}
237	cfg, _ := LoadConfig()
238	if cfg.Model != "" {
239		model, err := provider.FindModel(cfg.Provider, cfg.Model)
240		if err == nil && compatible(model) {
241			return nil
242		}
243	}
244	for _, id := range m.Model.Preferred {
245		model, err := provider.FindModel("", id)
246		if err == nil && compatible(model) {
247			args.Provider = model.Provider
248			args.Model = model.ID
249			return nil
250		}
251	}
252	for _, model := range provider.Active() {
253		if compatible(model) {
254			args.Provider = model.Provider
255			args.Model = model.ID
256			return nil
257		}
258	}
259	return fmt.Errorf("no catalog model satisfies the agent requirements")
260}
261
262func prepareRuntimeCatalog() {
263	LoadCachedModels()
264	LoadUserModels()
265	if cps := provider.CustomProviders(); len(cps) > 0 {
266		var names []string
267		for name := range cps {
268			if !isBuiltinProvider(name) {
269				names = append(names, name)
270			}
271		}
272		auth.SetExtraAPIKeyProviders(names)
273	}
274	ValidateAndRepairConfig()
275	RefreshModelsAsync()
276}
277
278func zotPack(dir, out string) error {
279	abs, err := filepath.Abs(dir)
280	if err != nil {
281		return err
282	}
283	if _, err := readZotManifest(abs); err != nil {
284		return err
285	}
286	if err := validateZotfileDir(abs); err != nil {
287		return err
288	}
289	if out == "" {
290		m, _ := readZotManifest(abs)
291		base := safeAgentName(m.Name)
292		if base == "" {
293			base = filepath.Base(abs)
294		}
295		out = base + ".zot"
296	}
297	if filepath.Ext(out) != ".zot" {
298		out += ".zot"
299	}
300	outAbs, err := filepath.Abs(out)
301	if err != nil {
302		return err
303	}
304	f, err := os.Create(out)
305	if err != nil {
306		return err
307	}
308	defer f.Close()
309	h := sha256.New()
310	mw := io.MultiWriter(f, h)
311	enc, err := zstd.NewWriter(mw)
312	if err != nil {
313		return err
314	}
315	if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
316		enc.Close()
317		return err
318	}
319	if err := enc.Close(); err != nil {
320		return err
321	}
322	fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
323	return nil
324}
325
326func loadZotfile(ref string) (zotfileLoaded, func(), error) {
327	resolved, err := resolveZotfileRef(ref)
328	if err != nil {
329		return zotfileLoaded{}, nil, err
330	}
331	ref = resolved
332	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
333		return loadRemoteZotfile(u)
334	}
335	info, err := os.Stat(ref)
336	if err != nil {
337		return zotfileLoaded{}, nil, err
338	}
339	if info.IsDir() {
340		abs, _ := filepath.Abs(ref)
341		m, err := readZotManifest(abs)
342		if err != nil {
343			return zotfileLoaded{}, nil, err
344		}
345		if err := validateZotfileDir(abs); err != nil {
346			return zotfileLoaded{}, nil, err
347		}
348		return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
349	}
350	tmp, err := os.MkdirTemp("", "zotfile-*")
351	if err != nil {
352		return zotfileLoaded{}, nil, err
353	}
354	cleanup := func() { _ = os.RemoveAll(tmp) }
355	digest, err := unpackZotArchive(ref, tmp)
356	if err != nil {
357		cleanup()
358		return zotfileLoaded{}, nil, err
359	}
360	m, err := readZotManifest(tmp)
361	if err != nil {
362		cleanup()
363		return zotfileLoaded{}, nil, err
364	}
365	if err := validateZotfileDir(tmp); err != nil {
366		cleanup()
367		return zotfileLoaded{}, nil, err
368	}
369	return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
370}
371
372const officialZotfileCollection = "https://github.com/patriceckhart/agents"
373
374func resolveZotfileRef(ref string) (string, error) {
375	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
376		return ref, nil
377	}
378	if _, err := os.Stat(ref); err == nil {
379		return ref, nil
380	} else if !os.IsNotExist(err) {
381		return "", err
382	}
383	if filepath.Ext(ref) == "" {
384		archive := ref + ".zot"
385		if _, err := os.Stat(archive); err == nil {
386			return archive, nil
387		} else if !os.IsNotExist(err) {
388			return "", err
389		}
390	}
391	if strings.HasSuffix(strings.ToLower(ref), ".zot") {
392		return ref, nil
393	}
394	collectionRef := strings.ReplaceAll(ref, "\\", "/")
395	parts := strings.Split(collectionRef, "/")
396	if len(parts) == 1 && validZotfileCollectionSegment(parts[0]) {
397		return officialZotfileCollection + "/" + parts[0], nil
398	}
399	if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) {
400		return "https://github.com/patriceckhart/" + parts[0] + "/" + parts[1], nil
401	}
402	return ref, nil
403}
404
405func validZotfileCollectionSegment(s string) bool {
406	return s != "" && s == strings.ToLower(s) && safeAgentName(s) == s
407}
408
409var zotfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
410
411var githubArchiveURL = func(owner, repo, ref string) string {
412	return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
413}
414
415func loadRemoteZotfile(u *url.URL) (zotfileLoaded, func(), error) {
416	if !strings.EqualFold(u.Hostname(), "github.com") {
417		return zotfileLoaded{}, nil, fmt.Errorf("unsupported zotfile URL host %q; only github.com agent directories are supported", u.Hostname())
418	}
419	owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
420	if err != nil {
421		return zotfileLoaded{}, nil, err
422	}
423	tmp, err := os.MkdirTemp("", "zotfile-github-*")
424	if err != nil {
425		return zotfileLoaded{}, nil, err
426	}
427	cleanup := func() { _ = os.RemoveAll(tmp) }
428	if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
429		cleanup()
430		return zotfileLoaded{}, nil, err
431	}
432	root, err := singleExtractedRoot(tmp)
433	if err != nil {
434		cleanup()
435		return zotfileLoaded{}, nil, err
436	}
437	dir := filepath.Join(root, filepath.FromSlash(subdir))
438	if subdir == "" {
439		dir = root
440	}
441	if !pathWithin(root, dir) {
442		cleanup()
443		return zotfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
444	}
445	m, err := readZotManifest(dir)
446	if err != nil {
447		cleanup()
448		return zotfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
449	}
450	if err := validateZotfileDir(dir); err != nil {
451		cleanup()
452		return zotfileLoaded{}, nil, err
453	}
454	return zotfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
455}
456
457func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
458	parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
459	for i := range parts {
460		parts[i], err = url.PathUnescape(parts[i])
461		if err != nil {
462			return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
463		}
464	}
465	if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
466		return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
467	}
468	owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
469	if len(parts) >= 4 && parts[2] == "tree" {
470		ref = parts[3]
471		subdir = strings.Join(parts[4:], "/")
472	} else {
473		subdir = strings.Join(parts[2:], "/")
474	}
475	if repo == "" || ref == "" {
476		return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
477	}
478	clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
479	if clean == "." {
480		clean = ""
481	}
482	if clean == ".." || strings.HasPrefix(clean, "../") {
483		return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
484	}
485	return owner, repo, ref, clean, nil
486}
487
488func downloadGitHubArchive(archiveURL, dst string) error {
489	req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
490	if err != nil {
491		return err
492	}
493	req.Header.Set("User-Agent", "zot")
494	resp, err := zotfileHTTPClient.Do(req)
495	if err != nil {
496		return fmt.Errorf("download GitHub repository: %w", err)
497	}
498	defer resp.Body.Close()
499	if resp.StatusCode != http.StatusOK {
500		return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
501	}
502	limited := &io.LimitedReader{R: resp.Body, N: maxZotfileCompressedSize + 1}
503	gr, err := gzip.NewReader(limited)
504	if err != nil {
505		return fmt.Errorf("read GitHub archive: %w", err)
506	}
507	defer gr.Close()
508	if err := untar(gr, dst); err != nil {
509		return fmt.Errorf("extract GitHub archive: %w", err)
510	}
511	if limited.N <= 0 {
512		return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
513	}
514	return nil
515}
516
517func pathWithin(root, target string) bool {
518	rel, err := filepath.Rel(root, target)
519	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
520}
521
522func singleExtractedRoot(dir string) (string, error) {
523	entries, err := os.ReadDir(dir)
524	if err != nil {
525		return "", err
526	}
527	if len(entries) != 1 || !entries[0].IsDir() {
528		return "", fmt.Errorf("GitHub archive has an unexpected layout")
529	}
530	return filepath.Join(dir, entries[0].Name()), nil
531}
532
533func readZotManifest(dir string) (ZotfileManifest, error) {
534	var m ZotfileManifest
535	b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
536	if err != nil {
537		return m, fmt.Errorf("manifest.json is required: %w", err)
538	}
539	if err := json.Unmarshal(b, &m); err != nil {
540		return m, fmt.Errorf("manifest.json: %w", err)
541	}
542	if m.Zotfile != 1 {
543		return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
544	}
545	name := strings.TrimSpace(m.Name)
546	if name == "" {
547		return m, fmt.Errorf("manifest name is required")
548	}
549	if name != strings.ToLower(name) || safeAgentName(name) != name {
550		return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
551	}
552	if len(m.Permissions.Net.Allow) > 0 {
553		return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
554	}
555	if len(m.Permissions.Env.Read) > 0 {
556		return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
557	}
558	mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
559	if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
560		return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
561	}
562	if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
563		return m, fmt.Errorf("bash allowlist mode requires at least one command")
564	}
565	return m, nil
566}
567
568func validateZotfileDir(dir string) error {
569	st, err := os.Stat(filepath.Join(dir, "AGENT.md"))
570	if err != nil || !st.Mode().IsRegular() {
571		return fmt.Errorf("AGENT.md is required")
572	}
573	if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
574		return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
575	}
576	return nil
577}
578
579const (
580	maxZotfileCompressedSize = 100 << 20
581	maxZotfileEntrySize      = 64 << 20
582	maxZotfileExpandedSize   = 256 << 20
583)
584
585func unpackZotArchive(path, dst string) (string, error) {
586	info, err := os.Stat(path)
587	if err != nil {
588		return "", err
589	}
590	if info.Size() > maxZotfileCompressedSize {
591		return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
592	}
593	b, err := os.ReadFile(path)
594	if err != nil {
595		return "", err
596	}
597	digest := sha256.Sum256(b)
598	r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
599	if err != nil {
600		// Development fallback for older experiments.
601		gr, gerr := gzip.NewReader(bytes.NewReader(b))
602		if gerr != nil {
603			return "", err
604		}
605		defer gr.Close()
606		return hex.EncodeToString(digest[:]), untar(gr, dst)
607	}
608	defer r.Close()
609	return hex.EncodeToString(digest[:]), untar(r, dst)
610}
611
612func untar(r io.Reader, dst string) error {
613	tr := tar.NewReader(r)
614	var expanded int64
615	for {
616		h, err := tr.Next()
617		if err == io.EOF {
618			return nil
619		}
620		if err != nil {
621			return err
622		}
623		name := filepath.Clean(h.Name)
624		if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
625			return fmt.Errorf("unsafe path in archive: %s", h.Name)
626		}
627		path := filepath.Join(dst, name)
628		if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize {
629			return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
630		}
631		expanded += h.Size
632		switch h.Typeflag {
633		case tar.TypeDir:
634			if err := os.MkdirAll(path, 0o755); err != nil {
635				return err
636			}
637		case tar.TypeReg:
638			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
639				return err
640			}
641			f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
642			if err != nil {
643				return err
644			}
645			_, cerr := io.Copy(f, tr)
646			if err := f.Close(); err != nil && cerr == nil {
647				cerr = err
648			}
649			if cerr != nil {
650				return cerr
651			}
652		}
653	}
654}
655
656func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
657	excluded := map[string]bool{}
658	for _, p := range exclude {
659		if p == "" {
660			continue
661		}
662		abs, err := filepath.Abs(p)
663		if err == nil {
664			excluded[abs] = true
665		}
666	}
667	var files []string
668	if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
669		if err != nil || path == root {
670			return err
671		}
672		absPath, _ := filepath.Abs(path)
673		if excluded[absPath] {
674			return nil
675		}
676		if d.Type()&os.ModeSymlink != 0 {
677			return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
678		}
679		files = append(files, path)
680		return nil
681	}); err != nil {
682		return err
683	}
684	sort.Strings(files)
685	tw := tar.NewWriter(w)
686	defer tw.Close()
687	fixed := time.Unix(0, 0)
688	for _, path := range files {
689		info, err := os.Stat(path)
690		if err != nil {
691			return err
692		}
693		rel, _ := filepath.Rel(root, path)
694		rel = filepath.ToSlash(rel)
695		h, err := tar.FileInfoHeader(info, "")
696		if err != nil {
697			return err
698		}
699		h.Name = rel
700		h.ModTime = fixed
701		h.AccessTime = fixed
702		h.ChangeTime = fixed
703		h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
704		if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
705			h.Name += "/"
706		}
707		if err := tw.WriteHeader(h); err != nil {
708			return err
709		}
710		if info.Mode().IsRegular() {
711			f, err := os.Open(path)
712			if err != nil {
713				return err
714			}
715			_, cerr := io.Copy(tw, f)
716			_ = f.Close()
717			if cerr != nil {
718				return cerr
719			}
720		}
721	}
722	return nil
723}
724
725func checkZotfileRequirements(zf zotfileLoaded, version string) error {
726	if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" {
727		if versionOnly(version) == "0.0.0" {
728			return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min)
729		}
730		if versionLess(version, min) {
731			return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version))
732		}
733	}
734	if len(zf.Manifest.Requirements.OS) > 0 {
735		ok := false
736		for _, osName := range zf.Manifest.Requirements.OS {
737			if osName == runtime.GOOS {
738				ok = true
739				break
740			}
741		}
742		if !ok {
743			return fmt.Errorf("agent does not support %s", runtime.GOOS)
744		}
745	}
746	if len(zf.Manifest.Requirements.Bin) > 0 {
747		for _, b := range zf.Manifest.Requirements.Bin {
748			if _, err := execLookPath(b); err != nil {
749				return fmt.Errorf("agent requires binary %q", b)
750			}
751		}
752	}
753	return nil
754}
755
756var execLookPath = exec.LookPath
757
758func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
759	if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
760		return true, nil
761	}
762	// "ask" deliberately requires approval on every launch. Other consent
763	// is durable only for this exact artifact digest, so any package change
764	// causes a fresh prompt.
765	consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
766	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
767		if _, err := os.Stat(consentPath); err == nil {
768			return true, nil
769		}
770	}
771	interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
772	th := tui.Dark
773	if interactive {
774		cfg, _ := LoadConfig()
775		th, _, _ = tui.DetectThemeWithCustom(ZotHome(), cfg.Theme, 80*time.Millisecond)
776	}
777	fmt.Print(formatZotfileConsent(zf, perms, th, interactive))
778	if !interactive {
779		return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
780	}
781	allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)
782	if err != nil || !allowed {
783		return allowed, err
784	}
785	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
786		return true, nil
787	}
788	if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
789		return false, fmt.Errorf("save agent consent: %w", err)
790	}
791	receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
792	data, _ := json.MarshalIndent(receipt, "", "  ")
793	if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
794		return false, fmt.Errorf("save agent consent: %w", err)
795	}
796	return true, nil
797}
798
799var errZotfileConsentInterrupted = errors.New("interrupted")
800
801func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
802	style := func(c int, text string) string {
803		if !color {
804			return text
805		}
806		return th.FG256(c, text)
807	}
808
809	var sb strings.Builder
810	sb.WriteString(style(th.Assistant, "Agent"))
811	sb.WriteByte(' ')
812	sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version))
813	sb.WriteString(style(th.FG, " wants to run."))
814	sb.WriteString("\n\n")
815	sb.WriteString(permissionSummaryStyled(perms, th, color))
816	if color {
817		sb.WriteByte('\n')
818		sb.WriteString(style(th.Assistant, "Allow?"))
819		sb.WriteByte(' ')
820		sb.WriteString(style(th.Muted, "[y/n]"))
821		sb.WriteByte(' ')
822	}
823	return sb.String()
824}
825
826// readZotfileConsent accepts one y/n answer followed by Enter without echoing
827// unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
828// be restored before the command aborts.
829func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) {
830	fd := int(in.Fd())
831	state, err := term.MakeRaw(fd)
832	if err != nil {
833		return false, fmt.Errorf("read agent consent: %w", err)
834	}
835	defer func() { _ = term.Restore(fd, state) }()
836
837	answer, err := readZotfileConsentKey(in, func(answer byte) {
838		if answer == 0 {
839			fmt.Fprint(out, "\b \b")
840			return
841		}
842		fmt.Fprint(out, th.FG256(th.FG, string(answer)))
843	})
844	switch {
845	case errors.Is(err, errZotfileConsentInterrupted):
846		fmt.Fprint(out, "^C\r\n")
847		return false, err
848	case err != nil:
849		fmt.Fprint(out, "\r\n")
850		return false, fmt.Errorf("read agent consent: %w", err)
851	default:
852		fmt.Fprint(out, "\r\n")
853		return answer == 'y', nil
854	}
855}
856
857func readZotfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
858	var answer byte
859	var b [1]byte
860	for {
861		if _, err := io.ReadFull(r, b[:]); err != nil {
862			return 0, err
863		}
864		switch b[0] {
865		case 'y', 'Y', 'n', 'N':
866			if answer == 0 {
867				answer = b[0] | 0x20
868				if echo != nil {
869					echo(answer)
870				}
871			}
872		case '\r', '\n':
873			if answer != 0 {
874				return answer, nil
875			}
876		case '\b', 0x7f:
877			if answer != 0 {
878				answer = 0
879				if echo != nil {
880					echo(0)
881				}
882			}
883		case 3: // Ctrl+C in raw mode.
884			return 0, errZotfileConsentInterrupted
885		}
886	}
887}
888
889func permissionSummary(p tools.PermissionSet) string {
890	return permissionSummaryStyled(p, tui.Theme{}, false)
891}
892
893func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
894	style := func(c int, text string) string {
895		if !color {
896			return text
897		}
898		return th.FG256(c, text)
899	}
900	permissionValue := func(value string) string { return style(th.FG, value) }
901
902	var sb strings.Builder
903	sb.WriteString(style(th.Muted, "  fs read: "))
904	if len(p.FS.Read) > 0 {
905		sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", ")))
906	} else {
907		sb.WriteString(style(th.Muted, "none"))
908	}
909	sb.WriteByte('\n')
910	sb.WriteString(style(th.Muted, "  fs write: "))
911	if len(p.FS.Write) > 0 {
912		sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", ")))
913	} else {
914		sb.WriteString(style(th.Muted, "none"))
915	}
916	sb.WriteByte('\n')
917	mode := p.Bash.Mode
918	if mode == "" {
919		mode = "none"
920	}
921	sb.WriteString(style(th.Muted, "  bash: "))
922	modeColor := th.FG
923	if mode == "ask" {
924		modeColor = th.Warning
925	}
926	sb.WriteString(style(modeColor, mode))
927	if len(p.Bash.Allow) > 0 {
928		sb.WriteString(style(th.Muted, " ("))
929		sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", ")))
930		sb.WriteString(style(th.Muted, ")"))
931	}
932	sb.WriteByte('\n')
933	if len(p.Net.Allow) > 0 {
934		sb.WriteString(style(th.Muted, "  net: "))
935		sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", ")))
936		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
937	}
938	if len(p.Env.Read) > 0 {
939		sb.WriteString(style(th.Muted, "  env read: "))
940		sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", ")))
941		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
942	}
943	return sb.String()
944}
945
946func bundledExtensionDirs(root string) []string {
947	entries, err := os.ReadDir(root)
948	if err != nil {
949		return nil
950	}
951	var out []string
952	for _, e := range entries {
953		if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
954			out = append(out, filepath.Join(root, e.Name()))
955		}
956	}
957	return out
958}
959
960func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
961func dirExists(p string) bool  { st, err := os.Stat(p); return err == nil && st.IsDir() }
962
963func safeAgentName(s string) string {
964	s = strings.TrimSpace(strings.ToLower(s))
965	s = strings.ReplaceAll(s, "/", "_")
966	s = strings.ReplaceAll(s, "\\", "_")
967	s = strings.Map(func(r rune) rune {
968		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
969			return r
970		}
971		return '-'
972	}, s)
973	return strings.Trim(s, "-._")
974}
975
976func digestDirectory(dir string) string {
977	h := sha256.New()
978	_ = writeCanonicalTar(dir, h)
979	return hex.EncodeToString(h.Sum(nil))
980}

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