v0.3.21 7fe8dbc

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

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