v0.38.1 969e52a

zutfile.go RawHistoryFolder

1package agent
2
3import (
4	"archive/tar"
5	"bytes"
6	"compress/gzip"
7	"context"
8	"crypto/sha256"
9	"encoding/hex"
10	"encoding/json"
11	"errors"
12	"fmt"
13	"io"
14	"net/http"
15	"net/url"
16	"os"
17	"os/exec"
18	"path/filepath"
19	"runtime"
20	"sort"
21	"strings"
22	"time"
23
24	"github.com/bnema/zut/packages/agent/tools"
25	"github.com/bnema/zut/packages/provider"
26	"github.com/bnema/zut/packages/provider/auth"
27	"github.com/bnema/zut/packages/tui"
28	"github.com/klauspost/compress/zstd"
29	"golang.org/x/term"
30)
31
32type ZutfileManifest struct {
33	Zutfile     int    `json:"zutfile"`
34	Name        string `json:"name"`
35	Version     string `json:"version"`
36	Description string `json:"description"`
37	License     string `json:"license"`
38	Runtime     struct {
39		MinZut string `json:"min_zut"`
40	} `json:"runtime"`
41	Model struct {
42		Requires   []string `json:"requires"`
43		MinContext int      `json:"min_context"`
44		Preferred  []string `json:"preferred"`
45		MinTier    string   `json:"min_tier"`
46	} `json:"model"`
47	Permissions  tools.PermissionSet `json:"permissions"`
48	Requirements struct {
49		Bin []string `json:"bin"`
50		OS  []string `json:"os"`
51	} `json:"requirements"`
52	Entry struct {
53		Greeting      string  `json:"greeting"`
54		Pre           string  `json:"pre"`
55		DefaultPrompt *string `json:"default_prompt"`
56	} `json:"entry"`
57	ReplaceSystemPrompt bool `json:"replace_system_prompt"`
58}
59
60type zutfileLoaded struct {
61	Dir      string
62	Temp     bool
63	Digest   string
64	Manifest ZutfileManifest
65}
66
67func runZutfileCommand(rawArgs []string, version string) (bool, error) {
68	if len(rawArgs) == 0 {
69		return false, nil
70	}
71	switch rawArgs[0] {
72	case "pack":
73		dir := "."
74		out := ""
75		if len(rawArgs) > 1 {
76			dir = rawArgs[1]
77		}
78		if len(rawArgs) > 2 {
79			out = rawArgs[2]
80		}
81		return true, zutPack(dir, out)
82	case "inspect":
83		if len(rawArgs) < 2 {
84			return true, fmt.Errorf("zut inspect requires a name, .zut file, directory, or GitHub URL")
85		}
86		return true, zutInspect(rawArgs[1])
87	case "verify":
88		if len(rawArgs) < 2 {
89			return true, fmt.Errorf("zut verify requires a name, .zut file, directory, or GitHub URL")
90		}
91		zf, cleanup, err := loadZutfile(rawArgs[1])
92		if cleanup != nil {
93			defer cleanup()
94		}
95		if err != nil {
96			return true, err
97		}
98		fmt.Printf("ok  digest sha256:%s\n", zf.Digest)
99		return true, nil
100	case "run":
101		if len(rawArgs) < 2 {
102			return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
103		}
104		yes, remaining := peelLeadingYes(rawArgs[1:])
105		if len(remaining) < 1 {
106			return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
107		}
108		ref := remaining[0]
109		rest := remaining[1:]
110		args, err := ParseArgs(rest)
111		if err != nil {
112			PrintHelp(version)
113			return true, err
114		}
115		if yes {
116			args.Yes = true
117		}
118		if args.Orchestrate {
119			return true, fmt.Errorf("--orchestrate is not supported for packaged agents")
120		}
121		if err := validateOrchestrationArgs(args); err != nil {
122			return true, err
123		}
124		return true, runLocalZutfile(ref, args, version)
125	default:
126		return false, nil
127	}
128}
129
130func runLocalZutfile(ref string, args Args, version string) error {
131	zf, cleanup, err := loadZutfile(ref)
132	if cleanup != nil {
133		defer cleanup()
134	}
135	if err != nil {
136		return err
137	}
138	if err := checkZutfileRequirements(zf, version); err != nil {
139		return err
140	}
141	// A packaged agent has not yet had its manifest permissions attached to
142	// Args. Reject orchestration at this boundary, before creating its data
143	// directory or asking for permission consent.
144	if args.Orchestrate {
145		args.AgentName = zf.Manifest.Name
146		return validateOrchestrationArgs(args)
147	}
148	agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
149	if err := os.MkdirAll(agentData, 0o755); err != nil {
150		return err
151	}
152	perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
153	allowed, err := consentZutfile(zf, perms, args.Yes)
154	if err != nil {
155		return err
156	}
157	if !allowed {
158		return nil
159	}
160	userModels := LoadUserModels()
161	catalogProvider, catalogModel := zutfileCatalogSelection(args, zf.Manifest, userModels)
162	PrepareRuntimeCatalog(context.Background(), true, catalogProvider, args.APIKey, args.BaseURL, userModels, catalogModel)
163	if err := applyZutfileModelRequirements(&args, zf.Manifest); err != nil {
164		return err
165	}
166	agentPath := filepath.Join(zf.Dir, "AGENT.md")
167	agentPrompt, err := os.ReadFile(agentPath)
168	switch {
169	case err == nil:
170		text := strings.TrimSpace(string(agentPrompt))
171		if zf.Manifest.ReplaceSystemPrompt {
172			args.SystemPrompt = text
173		} else {
174			args.AppendSystemPrompt = append(args.AppendSystemPrompt, text)
175		}
176	case os.IsNotExist(err):
177		// AGENT.md is optional; missing file leaves the system prompt unchanged.
178	default:
179		return fmt.Errorf("read AGENT.md: %w", err)
180	}
181	args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre)
182	if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
183		args.Prompt = *zf.Manifest.Entry.DefaultPrompt
184	}
185	if dirExists(filepath.Join(zf.Dir, "skills")) {
186		old := os.Getenv("ZUT_AGENT_SKILLS")
187		v := filepath.Join(zf.Dir, "skills")
188		if old != "" {
189			v += string(os.PathListSeparator) + old
190		}
191		_ = os.Setenv("ZUT_AGENT_SKILLS", v)
192		defer os.Setenv("ZUT_AGENT_SKILLS", old)
193	}
194	args.AgentName = zf.Manifest.Name
195	args.AgentDataDir = agentData
196	args.PermissionSet = &perms
197	return runWithArgs(args, version)
198}
199
200func zutInspect(ref string) error {
201	zf, cleanup, err := loadZutfile(ref)
202	if cleanup != nil {
203		defer cleanup()
204	}
205	if err != nil {
206		return err
207	}
208	m := zf.Manifest
209	fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
210	fmt.Println("\npermissions:")
211	fmt.Print(permissionSummary(m.Permissions))
212	fmt.Println("\nfiles:")
213	return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
214		if err != nil || path == zf.Dir {
215			return err
216		}
217		rel, _ := filepath.Rel(zf.Dir, path)
218		if d.IsDir() {
219			rel += "/"
220		}
221		fmt.Println("  " + filepath.ToSlash(rel))
222		return nil
223	})
224}
225
226// zutfileCatalogSelection derives the provider/model scope needed to refresh
227// dynamic catalogs before manifest preferences are applied. Unknown preferred
228// IDs remain useful as a model scope when the configured provider is dynamic.
229func zutfileCatalogSelection(args Args, m ZutfileManifest, userModels []provider.Model) (string, string) {
230	providerName := canonicalProvider(args.Provider)
231	modelID := strings.TrimSpace(args.Model)
232	if cfg, err := LoadConfig(); err == nil {
233		cfg.applyActiveModelProfile()
234		if providerName == "" {
235			providerName = canonicalProvider(cfg.Provider)
236		}
237		if modelID == "" {
238			modelID = strings.TrimSpace(cfg.Model)
239		}
240	}
241	if providerName == "" {
242		if _, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
243			providerName = provider.ProviderOpenCodeGo
244		}
245	}
246	findProvider := func(id string) string {
247		for _, model := range userModels {
248			if strings.TrimSpace(model.ID) == id {
249				return canonicalProvider(model.Provider)
250			}
251		}
252		if model, err := provider.FindModel("", id); err == nil {
253			return canonicalProvider(model.Provider)
254		}
255		return ""
256	}
257	if providerName == "" && modelID != "" {
258		providerName = findProvider(modelID)
259	}
260	if modelID == "" {
261		for _, preferred := range m.Model.Preferred {
262			preferred = strings.TrimSpace(preferred)
263			if preferred == "" {
264				continue
265			}
266			modelID = preferred
267			if providerName == "" {
268				providerName = findProvider(preferred)
269			}
270			break
271		}
272	}
273	return providerName, modelID
274}
275
276func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error {
277	minCtx := m.Model.MinContext
278	requires := map[string]bool{}
279	for _, capability := range m.Model.Requires {
280		requires[strings.ToLower(strings.TrimSpace(capability))] = true
281	}
282	for capability := range requires {
283		if capability != "tools" && capability != "vision" && capability != "reasoning" {
284			return fmt.Errorf("unsupported model requirement %q", capability)
285		}
286	}
287	if m.Model.MinTier != "" {
288		return fmt.Errorf("model.min_tier is not supported by this zut version")
289	}
290	compatible := func(model provider.Model) bool {
291		if model.ContextWindow < minCtx {
292			return false
293		}
294		if requires["reasoning"] && !model.Reasoning {
295			return false
296		}
297		// Every model exposed by the current catalog supports text and tools.
298		// Vision support is not represented in Model yet, so fail closed.
299		return !requires["vision"]
300	}
301	if minCtx <= 0 && len(requires) == 0 {
302		return nil
303	}
304	if args.Model != "" {
305		model, err := provider.FindModel(args.Provider, args.Model)
306		if err != nil {
307			model, err = provider.FindModel("", args.Model)
308		}
309		if err != nil {
310			return nil
311		}
312		if !compatible(model) {
313			return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
314		}
315		return nil
316	}
317	cfg, _ := LoadConfig()
318	if cfg.Model != "" {
319		model, err := provider.FindModel(cfg.Provider, cfg.Model)
320		if err == nil && compatible(model) {
321			return nil
322		}
323	}
324	for _, id := range m.Model.Preferred {
325		model, err := provider.FindModel("", id)
326		if err == nil && compatible(model) {
327			args.Provider = model.Provider
328			args.Model = model.ID
329			return nil
330		}
331	}
332	for _, model := range provider.Active() {
333		if compatible(model) {
334			args.Provider = model.Provider
335			args.Model = model.ID
336			return nil
337		}
338	}
339	return fmt.Errorf("no catalog model satisfies the agent requirements")
340}
341
342// PrepareRuntimeCatalog loads cached and user model metadata before a runtime
343// is resolved, then refreshes every eligible provider in the foreground or
344// background. The explicit endpoint is forwarded to account-scoped discovery
345// so credentials for a private OpenCode Go proxy never reach the production
346// endpoint. Callers pass the already-loaded user model slice so packaged-agent
347// startup does not parse models.json twice.
348func PrepareRuntimeCatalog(ctx context.Context, waitForRefresh bool, explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel ...string) {
349	if ctx == nil {
350		ctx = context.Background()
351	}
352	modelID := ""
353	if len(explicitModel) > 0 {
354		modelID = explicitModel[0]
355	}
356	catalogAPIKey := explicitAPIKey
357	catalogProvider := explicitProvider
358	if waitForRefresh && catalogAPIKey == "" {
359		catalogAPIKey = synchronousOpenCodeGoAPIKey(ctx, explicitProvider, explicitAPIKey)
360		if err := ctx.Err(); err != nil {
361			return
362		}
363		if catalogAPIKey != "" && effectiveCatalogProvider(explicitProvider) == "" {
364			catalogProvider = provider.ProviderOpenCodeGo
365		}
366	}
367	modelCatalogMu.Lock()
368	preparedProvider, preparedBaseURL := prepareRuntimeCatalog(catalogProvider, catalogAPIKey, explicitBaseURL, userModels, modelID)
369	modelCatalogMu.Unlock()
370	if waitForRefresh {
371		refreshModelsWithContext(ctx, preparedProvider, catalogAPIKey, preparedBaseURL, "", apiKeyCommandExecute)
372	} else {
373		RefreshModelsAsync(preparedProvider, explicitAPIKey, preparedBaseURL)
374	}
375}
376
377func effectiveCatalogProvider(explicitProvider string) string {
378	if providerName := canonicalProvider(explicitProvider); providerName != "" {
379		return providerName
380	}
381	cfg, err := LoadConfig()
382	if err != nil {
383		return ""
384	}
385	cfg.applyActiveModelProfile()
386	return canonicalProvider(cfg.Provider)
387}
388
389func effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL string, userModels []provider.Model) string {
390	baseURL := strings.TrimSpace(explicitBaseURL)
391	if baseURL != "" || explicitProvider != provider.ProviderOpenCodeGo {
392		return baseURL
393	}
394	modelID := strings.TrimSpace(explicitModel)
395	if modelID == "" {
396		if cfg, err := LoadConfig(); err == nil {
397			cfg.applyActiveModelProfile()
398			modelID = strings.TrimSpace(cfg.Model)
399		}
400	}
401	for _, model := range userModels {
402		if model.Provider != provider.ProviderOpenCodeGo {
403			continue
404		}
405		if modelID == "" || strings.TrimSpace(model.ID) == modelID {
406			return strings.TrimSpace(model.BaseURL)
407		}
408	}
409	if cfg, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
410		return strings.TrimSpace(cfg.BaseURL)
411	}
412	return ""
413}
414
415func prepareRuntimeCatalog(explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel string) (string, string) {
416	explicitProvider = effectiveCatalogProvider(explicitProvider)
417	explicitBaseURL = effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL, userModels)
418	loadCachedModels(modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL))
419	if cps := provider.CustomProviders(); len(cps) > 0 {
420		var names []string
421		for name := range cps {
422			if !isBuiltinProvider(name) {
423				names = append(names, name)
424			}
425		}
426		auth.SetExtraAPIKeyProviders(names)
427	}
428	ValidateAndRepairConfig()
429	return explicitProvider, explicitBaseURL
430}
431
432func zutPack(dir, out string) error {
433	abs, err := filepath.Abs(dir)
434	if err != nil {
435		return err
436	}
437	if _, err := readZutManifest(abs); err != nil {
438		return err
439	}
440	if err := validateZutfileDir(abs); err != nil {
441		return err
442	}
443	if out == "" {
444		m, _ := readZutManifest(abs)
445		base := safeAgentName(m.Name)
446		if base == "" {
447			base = filepath.Base(abs)
448		}
449		out = base + ".zut"
450	}
451	if filepath.Ext(out) != ".zut" {
452		out += ".zut"
453	}
454	outAbs, err := filepath.Abs(out)
455	if err != nil {
456		return err
457	}
458	f, err := os.Create(out)
459	if err != nil {
460		return err
461	}
462	defer f.Close()
463	h := sha256.New()
464	mw := io.MultiWriter(f, h)
465	enc, err := zstd.NewWriter(mw)
466	if err != nil {
467		return err
468	}
469	if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
470		enc.Close()
471		return err
472	}
473	if err := enc.Close(); err != nil {
474		return err
475	}
476	fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
477	return nil
478}
479
480func loadZutfile(ref string) (zutfileLoaded, func(), error) {
481	resolved, err := resolveZutfileRef(ref)
482	if err != nil {
483		return zutfileLoaded{}, nil, err
484	}
485	ref = resolved
486	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
487		return loadRemoteZutfile(u)
488	}
489	info, err := os.Stat(ref)
490	if err != nil {
491		return zutfileLoaded{}, nil, err
492	}
493	if info.IsDir() {
494		abs, _ := filepath.Abs(ref)
495		m, err := readZutManifest(abs)
496		if err != nil {
497			return zutfileLoaded{}, nil, err
498		}
499		if err := validateZutfileDir(abs); err != nil {
500			return zutfileLoaded{}, nil, err
501		}
502		return zutfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
503	}
504	tmp, err := os.MkdirTemp("", "zutfile-*")
505	if err != nil {
506		return zutfileLoaded{}, nil, err
507	}
508	cleanup := func() { _ = os.RemoveAll(tmp) }
509	digest, err := unpackZutArchive(ref, tmp)
510	if err != nil {
511		cleanup()
512		return zutfileLoaded{}, nil, err
513	}
514	m, err := readZutManifest(tmp)
515	if err != nil {
516		cleanup()
517		return zutfileLoaded{}, nil, err
518	}
519	if err := validateZutfileDir(tmp); err != nil {
520		cleanup()
521		return zutfileLoaded{}, nil, err
522	}
523	return zutfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
524}
525
526func resolveZutfileRef(ref string) (string, error) {
527	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
528		return ref, nil
529	}
530	if _, err := os.Stat(ref); err == nil {
531		return ref, nil
532	} else if !os.IsNotExist(err) {
533		return "", err
534	}
535	if filepath.Ext(ref) == "" {
536		archive := ref + ".zut"
537		if _, err := os.Stat(archive); err == nil {
538			return archive, nil
539		} else if !os.IsNotExist(err) {
540			return "", err
541		}
542	}
543	if strings.HasSuffix(strings.ToLower(ref), ".zut") {
544		return ref, nil
545	}
546	githubRef := strings.ReplaceAll(ref, "\\", "/")
547	parts := strings.Split(githubRef, "/")
548	if len(parts) == 2 && validGitHubZutfileSegment(parts[0]) && validGitHubZutfileSegment(parts[1]) {
549		return "https://github.com/" + parts[0] + "/" + parts[1], nil
550	}
551	if len(parts) >= 3 {
552		for _, part := range parts {
553			if !validGitHubZutfileSegment(part) {
554				return ref, nil
555			}
556		}
557		return "https://github.com/" + strings.Join(parts, "/"), nil
558	}
559	return ref, nil
560}
561
562func validGitHubZutfileSegment(s string) bool {
563	return s != "" && s == strings.ToLower(s) && safeAgentName(s) == s
564}
565
566var zutfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
567
568var githubArchiveURL = func(owner, repo, ref string) string {
569	return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
570}
571
572func loadRemoteZutfile(u *url.URL) (zutfileLoaded, func(), error) {
573	if !strings.EqualFold(u.Hostname(), "github.com") {
574		return zutfileLoaded{}, nil, fmt.Errorf("unsupported zutfile URL host %q; only github.com agent directories are supported", u.Hostname())
575	}
576	owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
577	if err != nil {
578		return zutfileLoaded{}, nil, err
579	}
580	tmp, err := os.MkdirTemp("", "zutfile-github-*")
581	if err != nil {
582		return zutfileLoaded{}, nil, err
583	}
584	cleanup := func() { _ = os.RemoveAll(tmp) }
585	if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
586		cleanup()
587		return zutfileLoaded{}, nil, err
588	}
589	root, err := singleExtractedRoot(tmp)
590	if err != nil {
591		cleanup()
592		return zutfileLoaded{}, nil, err
593	}
594	dir := filepath.Join(root, filepath.FromSlash(subdir))
595	if subdir == "" {
596		dir = root
597	}
598	if !pathWithin(root, dir) {
599		cleanup()
600		return zutfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
601	}
602	m, err := readZutManifest(dir)
603	if err != nil {
604		cleanup()
605		return zutfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
606	}
607	if err := validateZutfileDir(dir); err != nil {
608		cleanup()
609		return zutfileLoaded{}, nil, err
610	}
611	return zutfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
612}
613
614func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
615	parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
616	for i := range parts {
617		parts[i], err = url.PathUnescape(parts[i])
618		if err != nil {
619			return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
620		}
621	}
622	if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
623		return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
624	}
625	owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
626	if len(parts) >= 4 && parts[2] == "tree" {
627		ref = parts[3]
628		subdir = strings.Join(parts[4:], "/")
629	} else {
630		subdir = strings.Join(parts[2:], "/")
631	}
632	if repo == "" || ref == "" {
633		return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
634	}
635	clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
636	if clean == "." {
637		clean = ""
638	}
639	if clean == ".." || strings.HasPrefix(clean, "../") {
640		return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
641	}
642	return owner, repo, ref, clean, nil
643}
644
645func downloadGitHubArchive(archiveURL, dst string) error {
646	req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
647	if err != nil {
648		return err
649	}
650	req.Header.Set("User-Agent", "zut")
651	resp, err := zutfileHTTPClient.Do(req)
652	if err != nil {
653		return fmt.Errorf("download GitHub repository: %w", err)
654	}
655	defer resp.Body.Close()
656	if resp.StatusCode != http.StatusOK {
657		return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
658	}
659	limited := &io.LimitedReader{R: resp.Body, N: maxZutfileCompressedSize + 1}
660	gr, err := gzip.NewReader(limited)
661	if err != nil {
662		return fmt.Errorf("read GitHub archive: %w", err)
663	}
664	defer gr.Close()
665	if err := untar(gr, dst); err != nil {
666		return fmt.Errorf("extract GitHub archive: %w", err)
667	}
668	if limited.N <= 0 {
669		return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
670	}
671	return nil
672}
673
674func pathWithin(root, target string) bool {
675	rel, err := filepath.Rel(root, target)
676	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
677}
678
679func singleExtractedRoot(dir string) (string, error) {
680	entries, err := os.ReadDir(dir)
681	if err != nil {
682		return "", err
683	}
684	if len(entries) != 1 || !entries[0].IsDir() {
685		return "", fmt.Errorf("GitHub archive has an unexpected layout")
686	}
687	return filepath.Join(dir, entries[0].Name()), nil
688}
689
690func readZutManifest(dir string) (ZutfileManifest, error) {
691	var m ZutfileManifest
692	b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
693	if err != nil {
694		return m, fmt.Errorf("manifest.json is required: %w", err)
695	}
696	if err := json.Unmarshal(b, &m); err != nil {
697		return m, fmt.Errorf("manifest.json: %w", err)
698	}
699	if m.Zutfile != 1 {
700		return m, fmt.Errorf("unsupported zutfile version %d", m.Zutfile)
701	}
702	name := strings.TrimSpace(m.Name)
703	if name == "" {
704		return m, fmt.Errorf("manifest name is required")
705	}
706	if name != strings.ToLower(name) || safeAgentName(name) != name {
707		return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
708	}
709	if len(m.Permissions.Net.Allow) > 0 {
710		return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
711	}
712	if len(m.Permissions.Env.Read) > 0 {
713		return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
714	}
715	mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
716	if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
717		return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
718	}
719	if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
720		return m, fmt.Errorf("bash allowlist mode requires at least one command")
721	}
722	return m, nil
723}
724
725func validateZutfileDir(dir string) error {
726	agentPath := filepath.Join(dir, "AGENT.md")
727	st, err := os.Stat(agentPath)
728	switch {
729	case err == nil:
730		if !st.Mode().IsRegular() {
731			return fmt.Errorf("AGENT.md must be a regular file")
732		}
733	case os.IsNotExist(err):
734		// AGENT.md is optional.
735	default:
736		return fmt.Errorf("stat AGENT.md: %w", err)
737	}
738	if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
739		return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
740	}
741	return nil
742}
743
744const (
745	maxZutfileCompressedSize = 100 << 20
746	maxZutfileEntrySize      = 64 << 20
747	maxZutfileExpandedSize   = 256 << 20
748)
749
750func unpackZutArchive(path, dst string) (string, error) {
751	info, err := os.Stat(path)
752	if err != nil {
753		return "", err
754	}
755	if info.Size() > maxZutfileCompressedSize {
756		return "", fmt.Errorf("zutfile exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
757	}
758	b, err := os.ReadFile(path)
759	if err != nil {
760		return "", err
761	}
762	digest := sha256.Sum256(b)
763	r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZutfileExpandedSize))
764	if err != nil {
765		// Development fallback for older experiments.
766		gr, gerr := gzip.NewReader(bytes.NewReader(b))
767		if gerr != nil {
768			return "", err
769		}
770		defer gr.Close()
771		return hex.EncodeToString(digest[:]), untar(gr, dst)
772	}
773	defer r.Close()
774	return hex.EncodeToString(digest[:]), untar(r, dst)
775}
776
777func untar(r io.Reader, dst string) error {
778	tr := tar.NewReader(r)
779	var expanded int64
780	for {
781		h, err := tr.Next()
782		if err == io.EOF {
783			return nil
784		}
785		if err != nil {
786			return err
787		}
788		name := filepath.Clean(h.Name)
789		if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
790			return fmt.Errorf("unsafe path in archive: %s", h.Name)
791		}
792		path := filepath.Join(dst, name)
793		if h.Size < 0 || h.Size > maxZutfileEntrySize || expanded+h.Size > maxZutfileExpandedSize {
794			return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
795		}
796		expanded += h.Size
797		switch h.Typeflag {
798		case tar.TypeDir:
799			if err := os.MkdirAll(path, 0o755); err != nil {
800				return err
801			}
802		case tar.TypeReg:
803			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
804				return err
805			}
806			f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
807			if err != nil {
808				return err
809			}
810			_, cerr := io.Copy(f, tr)
811			if err := f.Close(); err != nil && cerr == nil {
812				cerr = err
813			}
814			if cerr != nil {
815				return cerr
816			}
817		}
818	}
819}
820
821func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
822	excluded := map[string]bool{}
823	for _, p := range exclude {
824		if p == "" {
825			continue
826		}
827		abs, err := filepath.Abs(p)
828		if err == nil {
829			excluded[abs] = true
830		}
831	}
832	var files []string
833	if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
834		if err != nil || path == root {
835			return err
836		}
837		absPath, _ := filepath.Abs(path)
838		if excluded[absPath] {
839			return nil
840		}
841		if d.Type()&os.ModeSymlink != 0 {
842			return fmt.Errorf("symlinks are not supported in zutfiles: %s", path)
843		}
844		files = append(files, path)
845		return nil
846	}); err != nil {
847		return err
848	}
849	sort.Strings(files)
850	tw := tar.NewWriter(w)
851	defer tw.Close()
852	fixed := time.Unix(0, 0)
853	for _, path := range files {
854		info, err := os.Stat(path)
855		if err != nil {
856			return err
857		}
858		rel, _ := filepath.Rel(root, path)
859		rel = filepath.ToSlash(rel)
860		h, err := tar.FileInfoHeader(info, "")
861		if err != nil {
862			return err
863		}
864		h.Name = rel
865		h.ModTime = fixed
866		h.AccessTime = fixed
867		h.ChangeTime = fixed
868		h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
869		if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
870			h.Name += "/"
871		}
872		if err := tw.WriteHeader(h); err != nil {
873			return err
874		}
875		if info.Mode().IsRegular() {
876			f, err := os.Open(path)
877			if err != nil {
878				return err
879			}
880			_, cerr := io.Copy(tw, f)
881			_ = f.Close()
882			if cerr != nil {
883				return cerr
884			}
885		}
886	}
887	return nil
888}
889
890func checkZutfileRequirements(zf zutfileLoaded, version string) error {
891	if min := strings.TrimSpace(zf.Manifest.Runtime.MinZut); min != "" {
892		if isDevVersion(version) {
893			return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
894		}
895		comparison, err := compareVersions(version, min)
896		if err != nil {
897			return fmt.Errorf("validate min_zut requirement: %w", err)
898		}
899		if comparison < 0 {
900			return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
901		}
902	}
903	if len(zf.Manifest.Requirements.OS) > 0 {
904		ok := false
905		for _, osName := range zf.Manifest.Requirements.OS {
906			if osName == runtime.GOOS {
907				ok = true
908				break
909			}
910		}
911		if !ok {
912			return fmt.Errorf("agent does not support %s", runtime.GOOS)
913		}
914	}
915	if len(zf.Manifest.Requirements.Bin) > 0 {
916		for _, b := range zf.Manifest.Requirements.Bin {
917			if _, err := execLookPath(b); err != nil {
918				return fmt.Errorf("agent requires binary %q", b)
919			}
920		}
921	}
922	return nil
923}
924
925var execLookPath = exec.LookPath
926
927func peelLeadingYes(args []string) (yes bool, rest []string) {
928	rest = args
929	for len(rest) > 0 {
930		switch rest[0] {
931		case "-y", "--yes":
932			yes = true
933			rest = rest[1:]
934		default:
935			return yes, rest
936		}
937	}
938	return yes, rest
939}
940
941func consentZutfile(zf zutfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
942	if autoYes || os.Getenv("ZUT_AGENT_CONSENT") == "1" {
943		return acceptZutfileConsent(zf, perms)
944	}
945	// "ask" deliberately requires approval on every launch. Other consent
946	// is durable only for this exact artifact digest, so any package change
947	// causes a fresh prompt.
948	consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
949	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
950		if _, err := os.Stat(consentPath); err == nil {
951			return true, nil
952		}
953	}
954	interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
955	th := tui.Dark
956	if interactive {
957		cfg, _ := LoadConfig()
958		th, _, _ = tui.DetectThemeWithCustom(ZutHome(), cfg.Theme, 80*time.Millisecond)
959	}
960	fmt.Print(formatZutfileConsent(zf, perms, th, interactive))
961	if !interactive {
962		return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZUT_AGENT_CONSENT=1 to allow")
963	}
964	allowed, err := readZutfileConsent(os.Stdin, os.Stdout, th)
965	if err != nil || !allowed {
966		return allowed, err
967	}
968	return acceptZutfileConsent(zf, perms)
969}
970
971// acceptZutfileConsent records durable consent when the bash mode is not
972// "ask". Ask mode never caches receipts so every launch re-prompts unless
973// -y / ZUT_AGENT_CONSENT is used for that run.
974func acceptZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet) (bool, error) {
975	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
976		return true, nil
977	}
978	consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
979	if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
980		return false, fmt.Errorf("save agent consent: %w", err)
981	}
982	receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
983	data, _ := json.MarshalIndent(receipt, "", "  ")
984	if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
985		return false, fmt.Errorf("save agent consent: %w", err)
986	}
987	return true, nil
988}
989
990var errZutfileConsentInterrupted = errors.New("interrupted")
991
992func formatZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
993	style := func(c tui.TerminalColor, text string) string {
994		if !color {
995			return text
996		}
997		return th.FGColor(c, text)
998	}
999
1000	var sb strings.Builder
1001	sb.WriteString(style(th.Assistant, "Agent"))
1002	sb.WriteByte(' ')
1003	sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version))
1004	sb.WriteString(style(th.FG, " wants to run."))
1005	sb.WriteString("\n\n")
1006	sb.WriteString(permissionSummaryStyled(perms, th, color))
1007	if color {
1008		sb.WriteByte('\n')
1009		sb.WriteString(style(th.Assistant, "Allow?"))
1010		sb.WriteByte(' ')
1011		sb.WriteString(style(th.Muted, "[y/n]"))
1012		sb.WriteByte(' ')
1013	}
1014	return sb.String()
1015}
1016
1017// readZutfileConsent accepts one y/n answer followed by Enter without echoing
1018// unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
1019// be restored before the command aborts.
1020func readZutfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) {
1021	fd := int(in.Fd())
1022	state, err := term.MakeRaw(fd)
1023	if err != nil {
1024		return false, fmt.Errorf("read agent consent: %w", err)
1025	}
1026	defer func() { _ = term.Restore(fd, state) }()
1027
1028	answer, err := readZutfileConsentKey(in, func(answer byte) {
1029		if answer == 0 {
1030			fmt.Fprint(out, "\b \b")
1031			return
1032		}
1033		fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
1034	})
1035	switch {
1036	case errors.Is(err, errZutfileConsentInterrupted):
1037		fmt.Fprint(out, "^C\r\n")
1038		return false, err
1039	case err != nil:
1040		fmt.Fprint(out, "\r\n")
1041		return false, fmt.Errorf("read agent consent: %w", err)
1042	default:
1043		fmt.Fprint(out, "\r\n")
1044		return answer == 'y', nil
1045	}
1046}
1047
1048func readZutfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
1049	var answer byte
1050	var b [1]byte
1051	for {
1052		if _, err := io.ReadFull(r, b[:]); err != nil {
1053			return 0, err
1054		}
1055		switch b[0] {
1056		case 'y', 'Y', 'n', 'N':
1057			if answer == 0 {
1058				answer = b[0] | 0x20
1059				if echo != nil {
1060					echo(answer)
1061				}
1062			}
1063		case '\r', '\n':
1064			if answer != 0 {
1065				return answer, nil
1066			}
1067		case '\b', 0x7f:
1068			if answer != 0 {
1069				answer = 0
1070				if echo != nil {
1071					echo(0)
1072				}
1073			}
1074		case 3: // Ctrl+C in raw mode.
1075			return 0, errZutfileConsentInterrupted
1076		}
1077	}
1078}
1079
1080func permissionSummary(p tools.PermissionSet) string {
1081	return permissionSummaryStyled(p, tui.Theme{}, false)
1082}
1083
1084func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
1085	style := func(c tui.TerminalColor, text string) string {
1086		if !color {
1087			return text
1088		}
1089		return th.FGColor(c, text)
1090	}
1091	permissionValue := func(value string) string { return style(th.FG, value) }
1092
1093	var sb strings.Builder
1094	sb.WriteString(style(th.Muted, "  fs read: "))
1095	if len(p.FS.Read) > 0 {
1096		sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", ")))
1097	} else {
1098		sb.WriteString(style(th.Muted, "none"))
1099	}
1100	sb.WriteByte('\n')
1101	sb.WriteString(style(th.Muted, "  fs write: "))
1102	if len(p.FS.Write) > 0 {
1103		sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", ")))
1104	} else {
1105		sb.WriteString(style(th.Muted, "none"))
1106	}
1107	sb.WriteByte('\n')
1108	mode := p.Bash.Mode
1109	if mode == "" {
1110		mode = "none"
1111	}
1112	sb.WriteString(style(th.Muted, "  bash: "))
1113	modeColor := th.FG
1114	if mode == "ask" {
1115		modeColor = th.Warning
1116	}
1117	sb.WriteString(style(modeColor, mode))
1118	if len(p.Bash.Allow) > 0 {
1119		sb.WriteString(style(th.Muted, " ("))
1120		sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", ")))
1121		sb.WriteString(style(th.Muted, ")"))
1122	}
1123	sb.WriteByte('\n')
1124	if len(p.Net.Allow) > 0 {
1125		sb.WriteString(style(th.Muted, "  net: "))
1126		sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", ")))
1127		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1128	}
1129	if len(p.Env.Read) > 0 {
1130		sb.WriteString(style(th.Muted, "  env read: "))
1131		sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", ")))
1132		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1133	}
1134	return sb.String()
1135}
1136
1137func bundledExtensionDirs(root string) []string {
1138	entries, err := os.ReadDir(root)
1139	if err != nil {
1140		return nil
1141	}
1142	var out []string
1143	for _, e := range entries {
1144		if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
1145			out = append(out, filepath.Join(root, e.Name()))
1146		}
1147	}
1148	return out
1149}
1150
1151func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
1152func dirExists(p string) bool  { st, err := os.Stat(p); return err == nil && st.IsDir() }
1153
1154func safeAgentName(s string) string {
1155	s = strings.TrimSpace(strings.ToLower(s))
1156	s = strings.ReplaceAll(s, "/", "_")
1157	s = strings.ReplaceAll(s, "\\", "_")
1158	s = strings.Map(func(r rune) rune {
1159		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
1160			return r
1161		}
1162		return '-'
1163	}, s)
1164	return strings.Trim(s, "-._")
1165}
1166
1167func digestDirectory(dir string) string {
1168	h := sha256.New()
1169	_ = writeCanonicalTar(dir, h)
1170	return hex.EncodeToString(h.Sum(nil))
1171}

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