Skip to content

Commit 4934c9e

Browse files
JAORMXclaude
andauthored
oci/plugins: Phase 1 OCI artifact package for plugins (THV-0077) (#136)
* oci/plugins: add Phase 1 OCI artifact package for plugins Add the `oci/plugins` package mirroring `oci/skills` file-for-file, built on the shared `oci/artifact` primitives from Phase 0. It packages a Claude Code plugin directory (`.claude-plugin/plugin.json` bundling commands, agents, skills, hooks, MCP/LSP server configs) into a reproducible, content-addressable multi-platform OCI artifact, and provides ORAS push/pull and a local store rooted at `toolhive/plugins`. - mediatypes.go: ArtifactTypePlugin "dev.toolhive.plugins.v1"; dev.toolhive.plugins.* labels/annotations including the plugin-specific .components inventory and .requires; PluginConfig + PluginConfigFromImageConfig. - interfaces.go: RegistryClient (Push/Pull), PluginPackager. - packager.go: index -> per-platform manifest -> config + single plugin.tar.gz layer, SOURCE_DATE_EPOCH-aware for reproducible digests. - registry.go: ORAS push/pull + Docker credential auth. - store.go: local OCI store under xdg.DataHome. - mocks/ via go:generate mockgen. Tests cover the exit gates (packager determinism, config round-trip, store put/get) plus end-to-end package -> push -> pull -> extract integration tests. Coverage 73.6%. Part of stacklok/toolhive#5525 Closes #131 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * oci/plugins,skills: address review feedback (security + determinism) Addresses jhrozek's review on PR #136. Blockers (plugins): - B1: validate digest before deriving blob path in Store.deleteBlob, plus a store-root prefix guard, preventing path traversal / arbitrary file deletion. - B2: Lstat + size-check the plugin manifest before reading it, rejecting a symlinked manifest (TOCTOU) and avoiding oversized allocation. - B3: Pull tags the local store under the full OCI reference instead of the bare tag, preventing cross-plugin tag collisions in the shared store. Other findings (plugins): - validatePluginDir: absolute-path guard (the post-Clean ".." check was inert). - fetchContent: bound local reads with io.LimitReader(MaxBlobSize). - gzip member header now honours opts.Epoch. - normalize zero-value PackageOptions.Epoch so config/tar/gzip agree. - maxPluginTotalSize lowered to 95 MB so tar overhead can't exceed the extraction-time MaxDecompressedSize limit. - preserve file modes through packaging (executable scripts stay executable). - retry.DefaultClient for transient registry errors. - DeleteBuild/DeleteTag/Tag serialized under a mutex. - json.Marshal of OCI labels/annotations now panics on the (invariant) error. Tests (plugins): ported TestStore_DeleteBuild, added an index-branch case, a deleteBlob invalid-digest guard, and a file-mode preservation test. Coverage 73.6% -> 81.7%. Parity fixes applied to oci/skills for findings marked "same in skills": B1, B3, fetchContent bound, gzip epoch, total-size limit, retry client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ddbfe47 commit 4934c9e

19 files changed

Lines changed: 3735 additions & 12 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ task license-fix # Add missing license headers
7474
| `logging` | Pre-configured `*slog.Logger` factory with consistent ToolHive defaults (Alpha) |
7575
| `oci/artifact` | Artifact-agnostic OCI tar/gzip/extraction/platform primitives shared by oci/skills and oci/plugins (Alpha) |
7676
| `oci/skills` | OCI artifact types, media types, and registry operations for ToolHive skills (Alpha) |
77+
| `oci/plugins` | OCI artifact types, media types, and registry operations for ToolHive plugins (Alpha) |
7778
| `postgres` | PostgreSQL connection pool with optional AWS RDS IAM dynamic auth (Alpha) |
7879
| `recovery` | HTTP panic recovery middleware (Beta) |
7980
| `validation/http` | RFC 7230/8707 compliant HTTP header and URI validation |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The ToolHive ecosystem spans multiple Go repositories, and several of these proj
2727
| `httperr` | Stable | Wrap errors with HTTP status codes |
2828
| `logging` | Alpha | Pre-configured `*slog.Logger` factory with consistent ToolHive defaults |
2929
| `oci/skills` | Alpha | OCI artifact types, media types, and registry operations for skills |
30+
| `oci/plugins` | Alpha | OCI artifact types, media types, and registry operations for plugins |
3031
| `postgres` | Alpha | PostgreSQL connection pool with optional AWS RDS IAM dynamic auth |
3132
| `recovery` | Beta | HTTP panic recovery middleware |
3233
| `validation/http` | Stable | RFC 7230/8707 compliant HTTP header and URI validation |

oci/plugins/doc.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/*
5+
Package plugins provides OCI artifact types, media types, local storage, and
6+
remote registry operations for ToolHive plugin packages.
7+
8+
A plugin is an OCI artifact containing a .claude-plugin/plugin.json manifest and
9+
its component directories. This package defines the constants, data structures,
10+
storage layer, packager, and registry client that the rest of the ToolHive
11+
ecosystem uses to package, push, pull, and cache plugins as OCI images.
12+
13+
# Media Types and Constants
14+
15+
Standard OCI media types and ToolHive-specific annotation/label keys:
16+
17+
// Artifact type identifies a plugin manifest
18+
plugins.ArtifactTypePlugin // "dev.toolhive.plugins.v1"
19+
20+
// Annotations carry metadata in manifests
21+
plugins.AnnotationPluginName
22+
plugins.AnnotationPluginVersion
23+
plugins.AnnotationPluginComponents
24+
25+
// Labels carry metadata in OCI image configs
26+
plugins.LabelPluginName
27+
plugins.LabelPluginFiles
28+
29+
# Registry Client
30+
31+
The [Registry] type implements [RegistryClient] for pushing and pulling plugin
32+
artifacts to/from OCI-compliant registries (GHCR, ECR, Docker Hub, etc.). It
33+
uses ORAS for registry operations and the Docker credential store for
34+
authentication by default:
35+
36+
reg, err := plugins.NewRegistry()
37+
// Push an artifact
38+
err = reg.Push(ctx, store, indexDigest, "ghcr.io/myorg/my-plugin:v1.0.0")
39+
// Pull an artifact
40+
digest, err := reg.Pull(ctx, store, "ghcr.io/myorg/my-plugin:v1.0.0")
41+
42+
Use functional options to customise behaviour:
43+
44+
reg, err := plugins.NewRegistry(
45+
plugins.WithPlainHTTP(true), // for local dev registries
46+
plugins.WithCredentialStore(myStore), // custom auth
47+
)
48+
49+
# Stability
50+
51+
This package is Alpha. Breaking changes are possible between minor versions.
52+
*/
53+
package plugins

oci/plugins/errors.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package plugins
5+
6+
import "errors"
7+
8+
// Sentinel errors returned (wrapped) by the packager so callers can classify
9+
// failures with errors.Is instead of matching error message strings. The
10+
// underlying error message is preserved at each call site via fmt.Errorf
11+
// with %w; only the classification is added.
12+
var (
13+
// ErrInvalidPluginDir indicates the plugin directory is missing, not a
14+
// directory, or otherwise unsafe to read (e.g. contains path traversal).
15+
ErrInvalidPluginDir = errors.New("invalid plugin directory")
16+
17+
// ErrPluginManifestMissing indicates .claude-plugin/plugin.json is not
18+
// present in the plugin directory.
19+
ErrPluginManifestMissing = errors.New(".claude-plugin/plugin.json missing")
20+
21+
// ErrInvalidPluginManifest indicates the plugin manifest is malformed,
22+
// oversized, or missing required fields such as the plugin name.
23+
ErrInvalidPluginManifest = errors.New("invalid plugin manifest")
24+
25+
// ErrTooManyFiles indicates the plugin directory exceeds the maximum
26+
// allowed number of files.
27+
ErrTooManyFiles = errors.New("too many files in plugin directory")
28+
29+
// ErrPluginTooLarge indicates the plugin directory exceeds the maximum
30+
// allowed total size.
31+
ErrPluginTooLarge = errors.New("plugin directory too large")
32+
33+
// ErrInvalidPluginFile indicates a per-file issue inside the plugin
34+
// directory: a symlink, a non-regular file, or an unreadable entry.
35+
ErrInvalidPluginFile = errors.New("invalid plugin file")
36+
)

0 commit comments

Comments
 (0)