Skip to content

feat(core): fetch the skills bundle from an immutable pin - #123

Open
dg-coreylweathers wants to merge 4 commits into
mainfrom
goal/bg-1-skills-bundle-fetch
Open

dg-coreylweathers wants to merge 4 commits into
mainfrom
goal/bg-1-skills-bundle-fetch

Conversation

@dg-coreylweathers

@dg-coreylweathers dg-coreylweathers commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

First PR of the five that replace #111. It adds one new module, deepctl_core.skill_bundle, plus its tests. Nothing calls it yet.

What it does

  • Fetches from an immutable pin. The default bundle is deepgram/skills commit 0fc13fad726fb78e17fb1f05ba5942f0d022990f (labeled deepgram-skills-v1.7.0). Its tarball must match sha256 5b7f9753…3980, and a mismatch raises SkillFetchError. The release tag is lightweight and could be moved, so it is kept only as a human-readable label and is never fetched.
  • Validates refs. --ref and DEEPCTL_SKILLS_REF accept only [A-Za-z0-9._/-], must start with a letter or digit, and may not contain .., // or /., or end in . or /. The cap is 100 characters, and a cache dir name may be at most 120 bytes, which keeps a typical Windows cache path under MAX_PATH. A user-supplied ref has no known hash, so it is not hash-checked, unless it equals the pinned commit.
  • Downloads from codeload with httpx, using a 30 s timeout and a 64 MiB cap. A 404 raises SkillRefNotFoundError, and an invalid ref raises SkillRefInvalidError.
  • Extracts the tarball safely. Every member is checked before anything is written.
    • Only regular files and directories are accepted.
    • It rejects absolute paths, .., \, : (drive letters, UNC paths, alternate data streams), symlinks, hardlinks and devices.
    • It rejects Windows reserved device names (CON, nul.txt, COM1, COM¹, CONOUT$…) and path components that end in a dot or a space.
    • It caps the member count, the total size and the name length.
    • Files are opened with xb, so an existing file is never overwritten.
  • Parses the manifest. It reads plugins[name=="deepgram"].skills from .claude-plugin/marketplace.json. Each entry must fully match (?:\./)?skills/<name>. The name must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ and must not be a Windows reserved name, end in a dot, or duplicate another name by case. All of this is checked before any Path is built.
  • Publishes the cache atomically to ~/.deepctl/skills/repo_cache/.
    • It extracts into a mkdtemp sibling, validates, then swaps with os.replace.
    • If the swap fails or is interrupted (Ctrl-C included), the previous cache goes back in place.
    • If a concurrent dg process publishes a valid cache for the same ref during the final rename, that copy is used instead of failing. Other overlaps fail cleanly and keep the old copy.
    • A symlink, or a non-empty directory without deepctl's marker, is refused and never moved or deleted. If the target changes between the ownership check and the move, the fetch is refused: a directory is put back, and anything else (a file or a symlink) is kept in staging and named in the error, so it is never overwritten. POSIX rename can replace an empty directory that appears in the rename window, but no file data is at risk.

What it does not do yet

It has no caller. It doesn't change skill_generator.py or any command, and it doesn't touch the README. Folder install and the dg skills commands come in goal 2. Concurrent fetches of the same ref don't lose data, but they aren't serialized. Goal 5's lock covers skills.json writes, not this cache. Returned paths are valid until the next fetch of the same ref replaces the cache, so goal 2's installer must treat a source folder that vanishes mid-copy as a retryable fetch error.

Greg's findings this closes (from the 2026-10-05 review on #111)

  • S3: the pin is now a commit SHA plus a tarball sha256, not a movable tag. Tests: TestPinnedDefault::test_tampered_archive_is_refused_and_leaves_nothing and ::test_tampered_archive_keeps_the_previous_cache.
  • B4: skill names are validated as single plain segments before any path is built. The TestManifest tests are parametrized over .., a/b, a\b, /abs, C:\x, C:x, ., con, NUL, com1 and more.

Ownership proof for every filesystem delete or move

  • The one rmtree: it removes only the staging dir this call created with tempfile.mkdtemp inside the cache root. Before deleting, it re-checks that the moved-aside old copy carries the marker. Staging is kept whenever it holds the only copy of the old cache, and the error names it (a bare Ctrl-C has no error to name it in).
  • The previous cache: it is moved into staging only if it is a real directory (not a symlink) carrying this module's marker.

How to review

  1. Read skill_bundle.py (399 lines). Its sections, in order:

    1. constants and caps
    2. errors and RepoSkill
    3. ref validation (validate_ref, resolve_skills_ref)
    4. fetch_skill_bundle
    5. manifest parsing
    6. download
    7. tar member checks and extraction
    8. _publish
  2. Check the pin yourself:

    • git ls-remote https://github.com/deepgram/skills refs/tags/deepgram-skills-v1.7.0
    • curl -sSL https://codeload.github.com/deepgram/skills/tar.gz/0fc13fad726fb78e17fb1f05ba5942f0d022990f | shasum -a 256

    Repeated downloads returned the same bytes.

  3. Skim the tests. They are grouped as TestPinnedDefault (S3), TestRefs, TestDownload, TestTarSafety, TestManifest (B4) and TestPublish, and none of them use the network.

Verification

  • Budget: 399 source lines against a limit of 400, measured with git diff --numstat excluding tests.
  • Docker gate: ruff format and check, mypy strict, and the full pytest suite (1682 passed) all pass. The core tests also pass as uid 1000 and on Python 3.10.
  • Upgrade check: not applicable. Nothing calls this module yet, so install behavior doesn't change.

Stacked series: 1/5. Tracking PR: #111.

🤖 Generated with Claude Code

Add deepctl_core.skill_bundle. It downloads the deepgram/skills tarball
from a pinned commit SHA, verifies the tarball sha256, extracts it with
strict member checks, and returns skill folders whose names are single
plain path segments. The cache is published atomically. Nothing calls it
yet.
A concurrent fetch that already published a valid cache now counts as
success instead of failing. Staging is kept whenever it holds the only
copy of the old cache. Windows reserved device names and trailing dots
or spaces are rejected in tar members and skill names. The ref length
cap drops to 100 to stay under MAX_PATH.
@dg-coreylweathers
dg-coreylweathers force-pushed the goal/bg-1-skills-bundle-fetch branch 4 times, most recently from e7d1a0f to 1344774 Compare October 5, 2026 13:52
The staging cleanup re-checks that the moved-aside copy carries the
deepctl marker. A target that changed between the check and the move is
refused: a directory is put back, and anything else (a file or a
symlink, dangling or not) is kept in staging and named in the error, so
nothing is overwritten. Refs with a segment starting with '.' are
rejected. Windows superscript COM/LPT and CONIN$/CONOUT$ names are
rejected, the cache name cap drops to 120 bytes for MAX_PATH, and errors
read as one sentence.

This branch has not been deployed

No deployments
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.

1 participant