detached cc6b04d

Run zotfile agents from GitHub URLs

commit
cc6b04dd75e678830f5a9be211f55e79e463c408
author
patriceckhart · · 2026-07-12 14:09 UTC
parent
082eb96
signature
unsigned
refs
tag: v0.2.78
4 files · +240 -9
M README.md +13 -1
@@ -26,7 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
2626 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
2727 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).
2828 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
29-- portable local agents packaged as `.zot` files; see [docs/zotfiles.md](docs/zotfiles.md).
29+- portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md).
3030
3131 ## Install
3232
@@ -212,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se
212212 - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
213213 - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
214214
215+## zotfile agents
216+
217+A zotfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zot` artifact, or directly from a public GitHub repository:
218+
219+```bash
220+zot run ./my-agent
221+zot run ./my-agent.zot
222+zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
223+```
224+
225+For a GitHub URL, zot downloads the repository archive into a temporary directory, validates and runs the selected agent, then removes the downloaded source when the command exits. Agent data, consent receipts, and sessions still persist under `$ZOT_HOME`. See [docs/zotfiles.md](docs/zotfiles.md) for authoring, permissions, packaging, and current limitations.
226+
215227 ## Embedding
216228
217229 Two ways to drive zot from another program:
@@ -26,7 +26,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
26 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).26 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
27 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).27 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).
28 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).28 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
29-- portable local agents packaged as `.zot` files; see [docs/zotfiles.md](docs/zotfiles.md).29+- portable agents from local directories, `.zot` files, or temporary public GitHub downloads; see [docs/zotfiles.md](docs/zotfiles.md).
30 30
31 ## Install31 ## Install
32 32
@@ -212,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se
212 - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).212 - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
213 - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.213 - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
214 214
215+## zotfile agents
216+
217+A zotfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zot` artifact, or directly from a public GitHub repository:
218+
219+```bash
220+zot run ./my-agent
221+zot run ./my-agent.zot
222+zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
223+```
224+
225+For a GitHub URL, zot downloads the repository archive into a temporary directory, validates and runs the selected agent, then removes the downloaded source when the command exits. Agent data, consent receipts, and sessions still persist under `$ZOT_HOME`. See [docs/zotfiles.md](docs/zotfiles.md) for authoring, permissions, packaging, and current limitations.
226+
215 ## Embedding227 ## Embedding
216 228
217 Two ways to drive zot from another program:229 Two ways to drive zot from another program:
M docs/zotfiles.md +20 -5
@@ -2,7 +2,7 @@
22
33 A Zotfile packages an agent's behavior into one portable `.zot` file. It can contain the agent's instructions, reusable skills, static assets, and metadata describing the runtime, model, operating-system, binary, and tool permissions it needs.
44
5-The current implementation is the **local runtime**. It supports creating, inspecting, verifying, and running local directories and `.zot` archives. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet.
5+The current implementation supports creating, inspecting, verifying, and running local directories and `.zot` archives. It can also run an agent directory directly from a public GitHub repository without keeping a clone. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet.
66
77 ## Quick start
88
@@ -494,7 +494,21 @@ zot run ./my-agent.zot --json "Do the task"
494494
495495 Arguments after the reference use zot's normal CLI parser, so model, provider, reasoning, cwd, session, tool, and output-mode flags remain available. The manifest still imposes its permission and compatibility ceiling.
496496
497-Only local filesystem directories and archive paths are accepted. Installed names, registry references, OCI references, and URLs are not resolved yet.
497+Local filesystem directories, local archive paths, and public GitHub agent-directory URLs are accepted. For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
498+
499+```bash
500+zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
501+```
502+
503+Both the short subdirectory form above and a standard GitHub tree URL are supported:
504+
505+```bash
506+zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
507+```
508+
509+The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
510+
511+Installed names, arbitrary URLs, registry references, and OCI references are not resolved yet.
498512
499513 ### `zot pack`
500514
@@ -519,6 +533,7 @@ Packing validates the manifest and directory, then creates a zstd-compressed tar
519533 ```bash
520534 zot inspect ./my-agent
521535 zot inspect ./my-agent.zot
536+zot inspect https://github.com/patriceckhart/agents/zot-maintenance
522537 ```
523538
524539 Inspection validates and prints the agent's name, version, description, digest, declared permissions, and complete file list. It does not execute the agent.
@@ -558,11 +573,12 @@ Archive extraction rejects absolute paths and parent traversal. Unsupported tar
558573 7. Inspect and verify the final archive, not only its source directory.
559574 8. Do not claim that network, environment, signatures, extensions, installation, or registry delivery are secured or supported by the local runtime.
560575
561-## Current scope and roadmap
576+## Current scope
562577
563578 Implemented now:
564579
565580 - local directories and `.zot` archives
581+- temporary execution of agent directories from public GitHub repositories
566582 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
567583 - canonical tar creation with zstd compression
568584 - archive digest reporting and safe extraction limits
@@ -579,8 +595,7 @@ Implemented now:
579595 Not implemented yet:
580596
581597 - `zot install`, `zot agents`, `zot use`, `zot update`, or `zot publish`
582-- installed-name, URL, OCI, or zot.sh index resolution
583-- signatures, signer identity, namespace ownership, or transparency history
598+- installed-name, arbitrary URL, OCI, or zot.sh index resolution
584599 - network allowlist enforcement
585600 - environment-variable filtering
586601 - safely confined bundled executable extensions
@@ -2,7 +2,7 @@
2 2
3 A Zotfile packages an agent's behavior into one portable `.zot` file. It can contain the agent's instructions, reusable skills, static assets, and metadata describing the runtime, model, operating-system, binary, and tool permissions it needs.3 A Zotfile packages an agent's behavior into one portable `.zot` file. It can contain the agent's instructions, reusable skills, static assets, and metadata describing the runtime, model, operating-system, binary, and tool permissions it needs.
4 4
5-The current implementation is the **local runtime**. It supports creating, inspecting, verifying, and running local directories and `.zot` archives. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet.5+The current implementation supports creating, inspecting, verifying, and running local directories and `.zot` archives. It can also run an agent directory directly from a public GitHub repository without keeping a clone. Registry distribution, installation, signatures, bundled executable extensions, network permissions, and environment permissions are not implemented yet.
6 6
7 ## Quick start7 ## Quick start
8 8
@@ -494,7 +494,21 @@ zot run ./my-agent.zot --json "Do the task"
494 494
495 Arguments after the reference use zot's normal CLI parser, so model, provider, reasoning, cwd, session, tool, and output-mode flags remain available. The manifest still imposes its permission and compatibility ceiling.495 Arguments after the reference use zot's normal CLI parser, so model, provider, reasoning, cwd, session, tool, and output-mode flags remain available. The manifest still imposes its permission and compatibility ceiling.
496 496
497-Only local filesystem directories and archive paths are accepted. Installed names, registry references, OCI references, and URLs are not resolved yet.497+Local filesystem directories, local archive paths, and public GitHub agent-directory URLs are accepted. For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
498+
499+```bash
500+zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
501+```
502+
503+Both the short subdirectory form above and a standard GitHub tree URL are supported:
504+
505+```bash
506+zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
507+```
508+
509+The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
510+
511+Installed names, arbitrary URLs, registry references, and OCI references are not resolved yet.
498 512
499 ### `zot pack`513 ### `zot pack`
500 514
@@ -519,6 +533,7 @@ Packing validates the manifest and directory, then creates a zstd-compressed tar
519 ```bash533 ```bash
520 zot inspect ./my-agent534 zot inspect ./my-agent
521 zot inspect ./my-agent.zot535 zot inspect ./my-agent.zot
536+zot inspect https://github.com/patriceckhart/agents/zot-maintenance
522 ```537 ```
523 538
524 Inspection validates and prints the agent's name, version, description, digest, declared permissions, and complete file list. It does not execute the agent.539 Inspection validates and prints the agent's name, version, description, digest, declared permissions, and complete file list. It does not execute the agent.
@@ -558,11 +573,12 @@ Archive extraction rejects absolute paths and parent traversal. Unsupported tar
558 7. Inspect and verify the final archive, not only its source directory.573 7. Inspect and verify the final archive, not only its source directory.
559 8. Do not claim that network, environment, signatures, extensions, installation, or registry delivery are secured or supported by the local runtime.574 8. Do not claim that network, environment, signatures, extensions, installation, or registry delivery are secured or supported by the local runtime.
560 575
561-## Current scope and roadmap576+## Current scope
562 577
563 Implemented now:578 Implemented now:
564 579
565 - local directories and `.zot` archives580 - local directories and `.zot` archives
581+- temporary execution of agent directories from public GitHub repositories
566 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`582 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
567 - canonical tar creation with zstd compression583 - canonical tar creation with zstd compression
568 - archive digest reporting and safe extraction limits584 - archive digest reporting and safe extraction limits
@@ -579,8 +595,7 @@ Implemented now:
579 Not implemented yet:595 Not implemented yet:
580 596
581 - `zot install`, `zot agents`, `zot use`, `zot update`, or `zot publish`597 - `zot install`, `zot agents`, `zot use`, `zot update`, or `zot publish`
582-- installed-name, URL, OCI, or zot.sh index resolution598+- installed-name, arbitrary URL, OCI, or zot.sh index resolution
583-- signatures, signer identity, namespace ownership, or transparency history
584 - network allowlist enforcement599 - network allowlist enforcement
585 - environment-variable filtering600 - environment-variable filtering
586 - safely confined bundled executable extensions601 - safely confined bundled executable extensions
M packages/agent/zotfile.go +132 -3
@@ -9,6 +9,8 @@ import (
99 "encoding/json"
1010 "fmt"
1111 "io"
12+ "net/http"
13+ "net/url"
1214 "os"
1315 "os/exec"
1416 "path/filepath"
@@ -75,12 +77,12 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
7577 return true, zotPack(dir, out)
7678 case "inspect":
7779 if len(rawArgs) < 2 {
78- return true, fmt.Errorf("zot inspect requires a .zot file or directory")
80+ return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL")
7981 }
8082 return true, zotInspect(rawArgs[1])
8183 case "verify":
8284 if len(rawArgs) < 2 {
83- return true, fmt.Errorf("zot verify requires a .zot file or directory")
85+ return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL")
8486 }
8587 zf, cleanup, err := loadZotfile(rawArgs[1])
8688 if cleanup != nil {
@@ -93,7 +95,7 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
9395 return true, nil
9496 case "run":
9597 if len(rawArgs) < 2 {
96- return true, fmt.Errorf("zot run requires a .zot file or directory")
98+ return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL")
9799 }
98100 ref := rawArgs[1]
99101 rest := rawArgs[2:]
@@ -316,6 +318,9 @@ func zotPack(dir, out string) error {
316318 }
317319
318320 func loadZotfile(ref string) (zotfileLoaded, func(), error) {
321+ if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
322+ return loadRemoteZotfile(u)
323+ }
319324 info, err := os.Stat(ref)
320325 if err != nil {
321326 return zotfileLoaded{}, nil, err
@@ -353,6 +358,130 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
353358 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354359 }
355360
361+var zotfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
362+
363+var githubArchiveURL = func(owner, repo, ref string) string {
364+ return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
365+}
366+
367+func loadRemoteZotfile(u *url.URL) (zotfileLoaded, func(), error) {
368+ if !strings.EqualFold(u.Hostname(), "github.com") {
369+ return zotfileLoaded{}, nil, fmt.Errorf("unsupported zotfile URL host %q; only github.com agent directories are supported", u.Hostname())
370+ }
371+ owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
372+ if err != nil {
373+ return zotfileLoaded{}, nil, err
374+ }
375+ tmp, err := os.MkdirTemp("", "zotfile-github-*")
376+ if err != nil {
377+ return zotfileLoaded{}, nil, err
378+ }
379+ cleanup := func() { _ = os.RemoveAll(tmp) }
380+ if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
381+ cleanup()
382+ return zotfileLoaded{}, nil, err
383+ }
384+ root, err := singleExtractedRoot(tmp)
385+ if err != nil {
386+ cleanup()
387+ return zotfileLoaded{}, nil, err
388+ }
389+ dir := filepath.Join(root, filepath.FromSlash(subdir))
390+ if subdir == "" {
391+ dir = root
392+ }
393+ if !pathWithin(root, dir) {
394+ cleanup()
395+ return zotfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
396+ }
397+ m, err := readZotManifest(dir)
398+ if err != nil {
399+ cleanup()
400+ return zotfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
401+ }
402+ if err := validateZotfileDir(dir); err != nil {
403+ cleanup()
404+ return zotfileLoaded{}, nil, err
405+ }
406+ return zotfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
407+}
408+
409+func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
410+ parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
411+ for i := range parts {
412+ parts[i], err = url.PathUnescape(parts[i])
413+ if err != nil {
414+ return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
415+ }
416+ }
417+ if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
418+ return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
419+ }
420+ owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
421+ if len(parts) >= 4 && parts[2] == "tree" {
422+ ref = parts[3]
423+ subdir = strings.Join(parts[4:], "/")
424+ } else {
425+ subdir = strings.Join(parts[2:], "/")
426+ }
427+ if repo == "" || ref == "" {
428+ return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
429+ }
430+ clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
431+ if clean == "." {
432+ clean = ""
433+ }
434+ if clean == ".." || strings.HasPrefix(clean, "../") {
435+ return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
436+ }
437+ return owner, repo, ref, clean, nil
438+}
439+
440+func downloadGitHubArchive(archiveURL, dst string) error {
441+ req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
442+ if err != nil {
443+ return err
444+ }
445+ req.Header.Set("User-Agent", "zot")
446+ resp, err := zotfileHTTPClient.Do(req)
447+ if err != nil {
448+ return fmt.Errorf("download GitHub repository: %w", err)
449+ }
450+ defer resp.Body.Close()
451+ if resp.StatusCode != http.StatusOK {
452+ return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
453+ }
454+ limited := &io.LimitedReader{R: resp.Body, N: maxZotfileCompressedSize + 1}
455+ gr, err := gzip.NewReader(limited)
456+ if err != nil {
457+ return fmt.Errorf("read GitHub archive: %w", err)
458+ }
459+ defer gr.Close()
460+ if err := untar(gr, dst); err != nil {
461+ return fmt.Errorf("extract GitHub archive: %w", err)
462+ }
463+ if limited.N <= 0 {
464+ return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
465+ }
466+ return nil
467+}
468+
469+func pathWithin(root, target string) bool {
470+ rel, err := filepath.Rel(root, target)
471+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
472+}
473+
474+func singleExtractedRoot(dir string) (string, error) {
475+ entries, err := os.ReadDir(dir)
476+ if err != nil {
477+ return "", err
478+ }
479+ if len(entries) != 1 || !entries[0].IsDir() {
480+ return "", fmt.Errorf("GitHub archive has an unexpected layout")
481+ }
482+ return filepath.Join(dir, entries[0].Name()), nil
483+}
484+
356485 func readZotManifest(dir string) (ZotfileManifest, error) {
357486 var m ZotfileManifest
358487 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
@@ -9,6 +9,8 @@ import (
9 "encoding/json"9 "encoding/json"
10 "fmt"10 "fmt"
11 "io"11 "io"
12+ "net/http"
13+ "net/url"
12 "os"14 "os"
13 "os/exec"15 "os/exec"
14 "path/filepath"16 "path/filepath"
@@ -75,12 +77,12 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
75 return true, zotPack(dir, out)77 return true, zotPack(dir, out)
76 case "inspect":78 case "inspect":
77 if len(rawArgs) < 2 {79 if len(rawArgs) < 2 {
78- return true, fmt.Errorf("zot inspect requires a .zot file or directory")80+ return true, fmt.Errorf("zot inspect requires a .zot file, directory, or GitHub URL")
79 }81 }
80 return true, zotInspect(rawArgs[1])82 return true, zotInspect(rawArgs[1])
81 case "verify":83 case "verify":
82 if len(rawArgs) < 2 {84 if len(rawArgs) < 2 {
83- return true, fmt.Errorf("zot verify requires a .zot file or directory")85+ return true, fmt.Errorf("zot verify requires a .zot file, directory, or GitHub URL")
84 }86 }
85 zf, cleanup, err := loadZotfile(rawArgs[1])87 zf, cleanup, err := loadZotfile(rawArgs[1])
86 if cleanup != nil {88 if cleanup != nil {
@@ -93,7 +95,7 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
93 return true, nil95 return true, nil
94 case "run":96 case "run":
95 if len(rawArgs) < 2 {97 if len(rawArgs) < 2 {
96- return true, fmt.Errorf("zot run requires a .zot file or directory")98+ return true, fmt.Errorf("zot run requires a .zot file, directory, or GitHub URL")
97 }99 }
98 ref := rawArgs[1]100 ref := rawArgs[1]
99 rest := rawArgs[2:]101 rest := rawArgs[2:]
@@ -316,6 +318,9 @@ func zotPack(dir, out string) error {
316 }318 }
317 319
318 func loadZotfile(ref string) (zotfileLoaded, func(), error) {320 func loadZotfile(ref string) (zotfileLoaded, func(), error) {
321+ if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
322+ return loadRemoteZotfile(u)
323+ }
319 info, err := os.Stat(ref)324 info, err := os.Stat(ref)
320 if err != nil {325 if err != nil {
321 return zotfileLoaded{}, nil, err326 return zotfileLoaded{}, nil, err
@@ -353,6 +358,130 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
353 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil358 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354 }359 }
355 360
361+var zotfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
362+
363+var githubArchiveURL = func(owner, repo, ref string) string {
364+ return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
365+}
366+
367+func loadRemoteZotfile(u *url.URL) (zotfileLoaded, func(), error) {
368+ if !strings.EqualFold(u.Hostname(), "github.com") {
369+ return zotfileLoaded{}, nil, fmt.Errorf("unsupported zotfile URL host %q; only github.com agent directories are supported", u.Hostname())
370+ }
371+ owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
372+ if err != nil {
373+ return zotfileLoaded{}, nil, err
374+ }
375+ tmp, err := os.MkdirTemp("", "zotfile-github-*")
376+ if err != nil {
377+ return zotfileLoaded{}, nil, err
378+ }
379+ cleanup := func() { _ = os.RemoveAll(tmp) }
380+ if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
381+ cleanup()
382+ return zotfileLoaded{}, nil, err
383+ }
384+ root, err := singleExtractedRoot(tmp)
385+ if err != nil {
386+ cleanup()
387+ return zotfileLoaded{}, nil, err
388+ }
389+ dir := filepath.Join(root, filepath.FromSlash(subdir))
390+ if subdir == "" {
391+ dir = root
392+ }
393+ if !pathWithin(root, dir) {
394+ cleanup()
395+ return zotfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
396+ }
397+ m, err := readZotManifest(dir)
398+ if err != nil {
399+ cleanup()
400+ return zotfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
401+ }
402+ if err := validateZotfileDir(dir); err != nil {
403+ cleanup()
404+ return zotfileLoaded{}, nil, err
405+ }
406+ return zotfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
407+}
408+
409+func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
410+ parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
411+ for i := range parts {
412+ parts[i], err = url.PathUnescape(parts[i])
413+ if err != nil {
414+ return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
415+ }
416+ }
417+ if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
418+ return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
419+ }
420+ owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
421+ if len(parts) >= 4 && parts[2] == "tree" {
422+ ref = parts[3]
423+ subdir = strings.Join(parts[4:], "/")
424+ } else {
425+ subdir = strings.Join(parts[2:], "/")
426+ }
427+ if repo == "" || ref == "" {
428+ return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
429+ }
430+ clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
431+ if clean == "." {
432+ clean = ""
433+ }
434+ if clean == ".." || strings.HasPrefix(clean, "../") {
435+ return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
436+ }
437+ return owner, repo, ref, clean, nil
438+}
439+
440+func downloadGitHubArchive(archiveURL, dst string) error {
441+ req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
442+ if err != nil {
443+ return err
444+ }
445+ req.Header.Set("User-Agent", "zot")
446+ resp, err := zotfileHTTPClient.Do(req)
447+ if err != nil {
448+ return fmt.Errorf("download GitHub repository: %w", err)
449+ }
450+ defer resp.Body.Close()
451+ if resp.StatusCode != http.StatusOK {
452+ return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
453+ }
454+ limited := &io.LimitedReader{R: resp.Body, N: maxZotfileCompressedSize + 1}
455+ gr, err := gzip.NewReader(limited)
456+ if err != nil {
457+ return fmt.Errorf("read GitHub archive: %w", err)
458+ }
459+ defer gr.Close()
460+ if err := untar(gr, dst); err != nil {
461+ return fmt.Errorf("extract GitHub archive: %w", err)
462+ }
463+ if limited.N <= 0 {
464+ return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
465+ }
466+ return nil
467+}
468+
469+func pathWithin(root, target string) bool {
470+ rel, err := filepath.Rel(root, target)
471+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
472+}
473+
474+func singleExtractedRoot(dir string) (string, error) {
475+ entries, err := os.ReadDir(dir)
476+ if err != nil {
477+ return "", err
478+ }
479+ if len(entries) != 1 || !entries[0].IsDir() {
480+ return "", fmt.Errorf("GitHub archive has an unexpected layout")
481+ }
482+ return filepath.Join(dir, entries[0].Name()), nil
483+}
484+
356 func readZotManifest(dir string) (ZotfileManifest, error) {485 func readZotManifest(dir string) (ZotfileManifest, error) {
357 var m ZotfileManifest486 var m ZotfileManifest
358 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))487 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
M packages/agent/zotfile_test.go +75 -0
@@ -3,6 +3,10 @@ package agent
33 import (
44 "archive/tar"
55 "bytes"
6+ "compress/gzip"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
610 "os"
711 "path/filepath"
812 "strings"
@@ -100,6 +104,77 @@ func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
100104 }
101105 }
102106
107+func TestParseGitHubAgentURL(t *testing.T) {
108+ tests := []struct {
109+ input string
110+ owner, repo, ref, dir string
111+ }{
112+ {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"},
113+ {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"},
114+ {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
115+ }
116+ for _, tt := range tests {
117+ u, err := url.Parse(tt.input)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ owner, repo, ref, dir, err := parseGitHubAgentURL(u)
122+ if err != nil {
123+ t.Fatalf("parse %s: %v", tt.input, err)
124+ }
125+ if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
126+ t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
127+ }
128+ }
129+}
130+
131+func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
132+ var archive bytes.Buffer
133+ gz := gzip.NewWriter(&archive)
134+ tw := tar.NewWriter(gz)
135+ files := map[string]string{
136+ "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`,
137+ "agents-main/zot-maintenance/AGENT.md": "Maintain zot.",
138+ }
139+ for name, content := range files {
140+ if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
141+ t.Fatal(err)
142+ }
143+ if _, err := tw.Write([]byte(content)); err != nil {
144+ t.Fatal(err)
145+ }
146+ }
147+ if err := tw.Close(); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := gz.Close(); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ w.Header().Set("Content-Type", "application/gzip")
156+ _, _ = w.Write(archive.Bytes())
157+ }))
158+ defer server.Close()
159+ oldArchiveURL := githubArchiveURL
160+ githubArchiveURL = func(_, _, _ string) string { return server.URL }
161+ t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
162+
163+ u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance")
164+ zf, cleanup, err := loadRemoteZotfile(u)
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ if zf.Manifest.Name != "zot-maintenance" || !zf.Temp {
169+ t.Fatalf("unexpected zotfile: %+v", zf)
170+ }
171+ root := filepath.Dir(filepath.Dir(zf.Dir))
172+ cleanup()
173+ if _, err := os.Stat(root); !os.IsNotExist(err) {
174+ t.Fatalf("temporary checkout was not removed: %v", err)
175+ }
176+}
177+
103178 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104179 got := permissionSummary(tools.PermissionSet{})
105180 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
@@ -3,6 +3,10 @@ package agent
3 import (3 import (
4 "archive/tar"4 "archive/tar"
5 "bytes"5 "bytes"
6+ "compress/gzip"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
6 "os"10 "os"
7 "path/filepath"11 "path/filepath"
8 "strings"12 "strings"
@@ -100,6 +104,77 @@ func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
100 }104 }
101 }105 }
102 106
107+func TestParseGitHubAgentURL(t *testing.T) {
108+ tests := []struct {
109+ input string
110+ owner, repo, ref, dir string
111+ }{
112+ {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"},
113+ {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"},
114+ {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
115+ }
116+ for _, tt := range tests {
117+ u, err := url.Parse(tt.input)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ owner, repo, ref, dir, err := parseGitHubAgentURL(u)
122+ if err != nil {
123+ t.Fatalf("parse %s: %v", tt.input, err)
124+ }
125+ if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
126+ t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
127+ }
128+ }
129+}
130+
131+func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
132+ var archive bytes.Buffer
133+ gz := gzip.NewWriter(&archive)
134+ tw := tar.NewWriter(gz)
135+ files := map[string]string{
136+ "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`,
137+ "agents-main/zot-maintenance/AGENT.md": "Maintain zot.",
138+ }
139+ for name, content := range files {
140+ if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
141+ t.Fatal(err)
142+ }
143+ if _, err := tw.Write([]byte(content)); err != nil {
144+ t.Fatal(err)
145+ }
146+ }
147+ if err := tw.Close(); err != nil {
148+ t.Fatal(err)
149+ }
150+ if err := gz.Close(); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ w.Header().Set("Content-Type", "application/gzip")
156+ _, _ = w.Write(archive.Bytes())
157+ }))
158+ defer server.Close()
159+ oldArchiveURL := githubArchiveURL
160+ githubArchiveURL = func(_, _, _ string) string { return server.URL }
161+ t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
162+
163+ u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance")
164+ zf, cleanup, err := loadRemoteZotfile(u)
165+ if err != nil {
166+ t.Fatal(err)
167+ }
168+ if zf.Manifest.Name != "zot-maintenance" || !zf.Temp {
169+ t.Fatalf("unexpected zotfile: %+v", zf)
170+ }
171+ root := filepath.Dir(filepath.Dir(zf.Dir))
172+ cleanup()
173+ if _, err := os.Stat(root); !os.IsNotExist(err) {
174+ t.Fatalf("temporary checkout was not removed: %v", err)
175+ }
176+}
177+
103 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {178 func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104 got := permissionSummary(tools.PermissionSet{})179 got := permissionSummary(tools.PermissionSet{})
105 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {180 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {

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