Skip to content

oci/plugins: Phase 1 OCI artifact package for plugins (THV-0077)#136

Merged
JAORMX merged 2 commits into
mainfrom
feat/oci-plugins-package
Jun 18, 2026
Merged

oci/plugins: Phase 1 OCI artifact package for plugins (THV-0077)#136
JAORMX merged 2 commits into
mainfrom
feat/oci-plugins-package

Conversation

@JAORMX

@JAORMX JAORMX commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What

Adds the oci/plugins package — Phase 1 of the plugin lifecycle epic (THV-0077). It mirrors oci/skills file-for-file, built on the shared oci/artifact primitives extracted in Phase 0.

A plugin is a directory declared by .claude-plugin/plugin.json, bundling slash commands, subagents, Agent Skills, hooks, and MCP/LSP server configs. This package builds such a directory into a reproducible, content-addressable multi-platform OCI artifact, pushes/pulls it via ORAS, and stores it locally.

Deliverables (per #131)

  • mediatypes.goArtifactTypePlugin = "dev.toolhive.plugins.v1"; full dev.toolhive.plugins.* label/annotation set (.name, .description, .version, .license, .files, plus plugin-specific .components inventory and .requires); PluginConfig + PluginConfigFromImageConfig.
  • interfaces.goRegistryClient (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 rooted at toolhive/plugins under xdg.DataHome.
  • mocks/ — generated via go:generate mockgen.

Testing

Exit gates covered: packager determinism under SOURCE_DATE_EPOCH, config round-trip (incl. zero-component nil regression guard), store put/get. Plus end-to-end package → push → pull → extract integration tests mirroring oci/skills.

  • task lint → 0 issues
  • task test (race) → all pass
  • coverage 73.6% (>70% graduation bar)
  • task license-check → clean

Review

A multi-axis review panel (spec / standards / architecture / security / devex / duplication) ran on the diff. No ship-blockers. In-scope findings applied: componentInventory now returns nil when empty (round-trip fix), a dedicated config round-trip test, and integration_test.go. Deferred (parity with oci/skills, separate follow-ups): extracting the byte-identical Store/Registry into oci/artifact, and Pull SSRF/PlainHTTP hardening.

Next gate

Precedes GATE-C2: tag toolhive-core after merge, then bump toolhive/go.mod. Nothing in Phase 2 (stacklok/toolhive#5526) compiles before that tag.

Part of stacklok/toolhive#5525
Closes #131

🤖 Generated with Claude Code

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>

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: oci/plugins package

This review combines findings from three parallel specialist agents (security, Go quality, architecture) plus the MoE review in REVIEW-e36cae86.md. The package is a well-structured mirror of oci/skills and the plugin-specific logic is clean. Three issues should be addressed before merge.

Blockers

  • B1 — path traversal in deleteBlob via unvalidated digest (arbitrary file deletion)
  • B2os.ReadFile before size check enables memory exhaustion; same line lacks Lstat guard (symlink TOCTOU)
  • B3Pull uses short ref as local tag, causing cross-plugin tag collisions in the shared store

Other significant findings

  • validatePluginDir path-traversal check is logically ineffective after filepath.Clean
  • fetchContent uses unbounded io.ReadAll on local blobs
  • SOURCE_DATE_EPOCH not applied to gzip member header
  • Zero-value PackageOptions.Epoch produces year-1 timestamps in config but 1970 in tar/gzip
  • maxPluginTotalSize (100 MB) + tar overhead can exceed MaxDecompressedSize (100 MB)
  • File modes stripped to 0644 — executable scripts lose their mode
  • No HTTP retry client in defaultNewTarget
  • DeleteBuild read-modify-write is not atomic
  • json.Marshal errors silently discarded in config/manifest creation
  • TestStore_DeleteBuild entirely absent — blob-deletion surface (5 functions) is 0% covered

Findings marked "same in skills" should be fixed in both packages.

Comment thread oci/plugins/store.go Outdated
Comment thread oci/plugins/packager.go Outdated
Comment thread oci/plugins/registry.go Outdated
Comment thread oci/plugins/packager.go
Comment thread oci/plugins/store.go Outdated
Comment thread oci/plugins/packager.go
Comment thread oci/plugins/registry.go
Comment thread oci/plugins/store.go
Comment thread oci/plugins/packager.go Outdated
Comment thread oci/plugins/store_test.go
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>
@JAORMX

JAORMX commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @jhrozek. All findings addressed in 169911d. Summary:

Blockers (plugins)

  • B1Store.deleteBlob now calls d.Validate() and guards the resolved path against escaping the store root before os.Remove.
  • B2readPluginDirectory now Lstats the manifest, rejects a symlink/non-regular file, and enforces maxManifestSize before os.ReadFile.
  • B3Pull passes the full ref as the oras.Copy destination tag and drops the separate store.Tag, so a bare tag can no longer collide across artifacts in the shared store.

Other findings (plugins)

  • validatePluginDir — replaced the inert post-Clean .. check with an absolute-path guard.
  • fetchContent — local reads bounded with io.LimitReader(artifact.MaxBlobSize+1).
  • gzip member header now honours opts.Epoch.
  • zero-value PackageOptions.Epoch normalized to Unix 0 so config/tar/gzip timestamps agree.
  • maxPluginTotalSize → 95 MB, below artifact.MaxDecompressedSize, so tar overhead can't make a packaged artifact un-unpackable.
  • file modes preserved through packaging (executable scripts keep their bit); added a regression test.
  • retry.DefaultClient wired into the auth client for transient registry errors.
  • DeleteBuild/DeleteTag/Tag serialized under a sync.Mutex (non-reentrant: public methods delegate to *Locked helpers).
  • json.Marshal of labels/annotations now panics on the invariant error via a mustMarshalJSON helper.

Tests — ported TestStore_DeleteBuild, added an ocispec.Index subcase covering the deleteOrphanedBlobs index branch, a deleteBlob invalid-digest guard, and a file-mode preservation test. Plugins coverage 73.6% → 81.7%.

Parity (oci/skills) — applied the findings you marked "same in skills": B1, B3, fetchContent bound, gzip epoch, total-size limit, and the retry client.

A couple of skills-only parity items I did not pull into this PR, to keep it scoped — happy to do either here or as a follow-up if you'd prefer:

  • B2's Lstat/size guard on SKILL.md (skills has its own frontmatter path; same TOCTOU class applies).
  • The DeleteBuild mutex (the race exists in skills too).

task is green (lint 0 issues, vet, race tests, license headers).

@JAORMX JAORMX merged commit 4934c9e into main Jun 18, 2026
5 checks passed
@JAORMX JAORMX deleted the feat/oci-plugins-package branch June 18, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[plugins] Phase 1: oci/plugins package (THV-0077)

2 participants