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. | |||
| 26 | 26 | - user and extension themes via JSON; see [docs/themes.md](docs/themes.md). | |
| 27 | 27 | - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd). | |
| 28 | 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 | 31 | ## Install | |
| 32 | 32 | ||
| @@ -212,6 +212,18 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||
| 212 | 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 | 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 | 227 | ## Embedding | |
| 216 | 228 | ||
| 217 | 229 | 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 | ## Install | 31 | ## 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 | ## Embedding | 227 | ## 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 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 7 | ## Quick start | |
| 8 | 8 | ||
| @@ -494,7 +494,21 @@ zot run ./my-agent.zot --json "Do the task" | |||
| 494 | 494 | ||
| 495 | 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 | 513 | ### `zot pack` | |
| 500 | 514 | ||
| @@ -519,6 +533,7 @@ Packing validates the manifest and directory, then creates a zstd-compressed tar | |||
| 519 | 533 | ```bash | |
| 520 | 534 | zot inspect ./my-agent | |
| 521 | 535 | zot inspect ./my-agent.zot | |
| 536 | + | zot inspect https://github.com/patriceckhart/agents/zot-maintenance | |
| 522 | 537 | ``` | |
| 523 | 538 | ||
| 524 | 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 | 573 | 7. Inspect and verify the final archive, not only its source directory. | |
| 559 | 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 roadmap | |
| 576 | + | ## Current scope | |
| 562 | 577 | ||
| 563 | 578 | Implemented now: | |
| 564 | 579 | ||
| 565 | 580 | - local directories and `.zot` archives | |
| 581 | + | - temporary execution of agent directories from public GitHub repositories | |
| 566 | 582 | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | |
| 567 | 583 | - canonical tar creation with zstd compression | |
| 568 | 584 | - archive digest reporting and safe extraction limits | |
| @@ -579,8 +595,7 @@ Implemented now: | |||
| 579 | 595 | Not implemented yet: | |
| 580 | 596 | ||
| 581 | 597 | - `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 | |
| 584 | 599 | - network allowlist enforcement | |
| 585 | 600 | - environment-variable filtering | |
| 586 | 601 | - 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 start | 7 | ## 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 | ```bash | 533 | ```bash | ||
| 520 | zot inspect ./my-agent | 534 | zot inspect ./my-agent | ||
| 521 | zot inspect ./my-agent.zot | 535 | 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 roadmap | 576 | + | ## Current scope |
| 562 | 577 | ||||
| 563 | Implemented now: | 578 | Implemented now: | ||
| 564 | 579 | ||||
| 565 | - local directories and `.zot` archives | 580 | - 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 compression | 583 | - canonical tar creation with zstd compression | ||
| 568 | - archive digest reporting and safe extraction limits | 584 | - 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 resolution | 598 | + | - installed-name, arbitrary URL, OCI, or zot.sh index resolution |
| 583 | - | - signatures, signer identity, namespace ownership, or transparency history | |||
| 584 | - network allowlist enforcement | 599 | - network allowlist enforcement | ||
| 585 | - environment-variable filtering | 600 | - environment-variable filtering | ||
| 586 | - safely confined bundled executable extensions | 601 | - safely confined bundled executable extensions | ||
M packages/agent/zotfile.go +132 -3
| @@ -9,6 +9,8 @@ import ( | |||
| 9 | 9 | "encoding/json" | |
| 10 | 10 | "fmt" | |
| 11 | 11 | "io" | |
| 12 | + | "net/http" | |
| 13 | + | "net/url" | |
| 12 | 14 | "os" | |
| 13 | 15 | "os/exec" | |
| 14 | 16 | "path/filepath" | |
| @@ -75,12 +77,12 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 75 | 77 | return true, zotPack(dir, out) | |
| 76 | 78 | case "inspect": | |
| 77 | 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 | 82 | return true, zotInspect(rawArgs[1]) | |
| 81 | 83 | case "verify": | |
| 82 | 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 | 87 | zf, cleanup, err := loadZotfile(rawArgs[1]) | |
| 86 | 88 | if cleanup != nil { | |
| @@ -93,7 +95,7 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 93 | 95 | return true, nil | |
| 94 | 96 | case "run": | |
| 95 | 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 | 100 | ref := rawArgs[1] | |
| 99 | 101 | rest := rawArgs[2:] | |
| @@ -316,6 +318,9 @@ func zotPack(dir, out string) error { | |||
| 316 | 318 | } | |
| 317 | 319 | ||
| 318 | 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 | 324 | info, err := os.Stat(ref) | |
| 320 | 325 | if err != nil { | |
| 321 | 326 | return zotfileLoaded{}, nil, err | |
| @@ -353,6 +358,130 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) { | |||
| 353 | 358 | 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 | 485 | func readZotManifest(dir string) (ZotfileManifest, error) { | |
| 357 | 486 | var m ZotfileManifest | |
| 358 | 487 | 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, nil | 95 | 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, err | 326 | 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, nil | 358 | 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 ZotfileManifest | 486 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "archive/tar" | |
| 5 | 5 | "bytes" | |
| 6 | + | "compress/gzip" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 6 | 10 | "os" | |
| 7 | 11 | "path/filepath" | |
| 8 | 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 | 178 | func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |
| 104 | 179 | got := permissionSummary(tools.PermissionSet{}) | |
| 105 | 180 | 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") { | ||