v0.3.30 2501d48

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

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