Skip to content

Record project-scope skill installs in the lock file - #5894

Merged
samuv merged 4 commits into
mainfrom
skills-lock/03-install-hooks
Jul 24, 2026
Merged

Record project-scope skill installs in the lock file#5894
samuv merged 4 commits into
mainfrom
skills-lock/03-install-hooks

Conversation

@samuv

@samuv samuv commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Why: RFC THV-0080 requires every project-scoped skill install to be pinned in toolhive.lock.yaml — including transitively materialized toolhive.requires dependencies — with lock-write failure hard-failing the install (never a silently-unpinned skill). This is the largest PR in the stack; flagging it up front for extra review time.
  • What:
    • Install hooks into the single existing choke point all install paths already funnel through (installAndRegister): compute contentDigest, upsert the lock entry, roll back the DB record (matching the existing group-registration rollback pattern) if the lock write fails.
    • toolhive.requires dependencies materialize recursively — a Visited set guards cycles, skills.MaxDependencies bounds the whole tree (not just one skill's declared list) — with RequiredBy merged (not overwritten) when a dependency is shared by multiple parents.
    • Uninstall cascades to orphaned, non-explicit dependencies via Lockfile.RemoveParentFromRequiredBy, itself cycle-safe.
    • The whole feature stays inert on main behind TOOLHIVE_SKILLS_LOCK_ENABLED until the full RFC v1 (this stack + the later Sigstore stack) lands — following the existing TOOLHIVE_DEV precedent (pkg/skills/gitresolver/reference.go) for staged rollouts. Every PR in the stack stays independently mergeable without exposing partial behavior to users.

Part of the production stack for #5715 (RFC THV-0080). Stack: 3/6 — lockfile → lock-service → install-hooks → sync → upgrade → cli-exitcodes. Based on #5893 (PR2).

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e, scoped: ginkgo --label-filter=skills-lock)
  • Linting (task lint-fix)

Unit coverage (86.3% for pkg/skills/skillsvc) targets the risk areas directly: contentDigest/source recorded correctly on install, dependency materialization, a dependency shared by two parents merging RequiredBy, a requires cycle terminating, the MaxDependencies tree-wide cap, lock-write failure rolling back the DB record, cascade uninstall (orphaned dependency removed, explicit dependency never cascaded, shared dependency survives one parent's removal, cascade cycle terminating). Two new E2E specs exercise the real HTTP API + thv serve subprocess + a real OCI push/install round-trip, confirming the lock file is written/removed correctly outside the unit-test harness.

Does this introduce a user-facing change?

Not yet — gated behind TOOLHIVE_SKILLS_LOCK_ENABLED, off by default. Once the full stack lands: project-scope thv skill install will write/update toolhive.lock.yaml and materialize toolhive.requires dependencies; thv skill uninstall will cascade-remove orphaned dependencies.

Special notes for reviewers

  • toolhive.requires is read back from the extracted SKILL.md on disk (not from the OCI/git resolver's own parse) — this works uniformly across OCI and git sources without needing to plumb requires through every install path's return type.
  • test/e2e/api_helpers.go's ServerConfig gains an ExtraEnv []string field (empty/no-op for every existing E2E test) so this PR's tests can opt into the feature flag on their own thv serve subprocess without turning it on for the whole E2E suite.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.93833% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.88%. Comparing base (97a9dfa) to head (187a35d).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/skills/skillsvc/content_digest.go 46.66% 8 Missing and 8 partials ⚠️
pkg/skills/skillsvc/uninstall.go 76.19% 9 Missing and 6 partials ⚠️
pkg/skills/skillsvc/lock.go 86.84% 5 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5894      +/-   ##
==========================================
+ Coverage   71.84%   71.88%   +0.03%     
==========================================
  Files         708      711       +3     
  Lines       72811    73022     +211     
==========================================
+ Hits        52311    52489     +178     
- Misses      16765    16784      +19     
- Partials     3735     3749      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 7824f3f to 1514a9d Compare July 21, 2026 16:58
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 1514a9d to 3dd34f4 Compare July 21, 2026 17:08
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 21, 2026
@samuv

samuv commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Tracked in #5899 (RFC THV-0080 stack).

@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from bdf19aa to 176f367 Compare July 22, 2026 20:13
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 22, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 773d3ff to 78b4c7d Compare July 23, 2026 11:54
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Panel review (spec / standards / security / library-reuse / duplication / golang-conventions / UX). Phase-2 mechanics are all correct — hard-fail on lock write, transitive requiredBy, dirhash contentDigest, source never rewritten, managed set. But there are two real correctness findings plus some standards/UX items. Commenting rather than approving.

Correctness (worth addressing before merge):

  1. Dependency-cycle deadlock via the OCI pathpkg/skills/skillsvc/lock.go (materializeDependencies) re-enters s.Install, but the Visited cycle check only runs inside recordLockState, after the artifact install. The git path works because installFromGit locks on the resolved skill name; a requires reference that dispatches through installByName/installFromOCI locks on opts.Name before resolution (install.go:100). A cycle A → oci:.../A re-locks a key the outer install already holds and deadlocks, with the Visited set never consulted. Suggest a pre-install cycle check keyed on the requested reference, or locking only on resolved names everywhere.

  2. Store/lock write ordering inverts the durable-first rulerecordLockState (lock.go) writes the lock entry before the store Update that marks the skill Managed. If the Update fails, rollback deletes the lock entry but the store record survives unmanaged with files still on disk — a state Uninstall won't cascade (it only cascades when existing.Managed). Mark Managed in the store first, or extend rollback to clear files/record on this failure.

Standards (mechanical): three go-style.md immutable-variable violations — the if x == "" { x = fallback } reassignment at lock.go:42, lock.go:91, uninstall.go:60. (library-reuse notes cmp.Or collapses the first to one line.)

UX: lock-write failures surface as generic HTTP 500s (install.go:267, uninstall.go:61) with no cause or recovery hint — a dead-end for the operator. Worth an actionable message. Also Managed isn't visible in skill list/skill info, so an operator can't tell why one uninstall touches the lock and another doesn't.

Security: verified clean on the main path (os.Root containment, atomic temp+rename, digest hardening, CWE-22). One Low TOCTOU: prevEntry snapshot is read outside the file lock (install.go:246) and could reinstate a stale entry on rollback under concurrent same-skill installs — take the snapshot inside Update's callback.

Deferred (confirm, not defects): Sigstore provenance recording and --allow-unsigned are Phase 5, correctly absent — acknowledged in feature_gate.go.

@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from 78b4c7d to bad7ade Compare July 24, 2026 08:05
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
Base automatically changed from skills-lock/02-lock-service to main July 24, 2026 08:35
samuv added 4 commits July 24, 2026 10:35
RFC THV-0080 requires every project-scoped skill install to be
pinned in toolhive.lock.yaml, including transitively materialized
toolhive.requires dependencies, with a fail-hard guarantee so a
lock-write failure never leaves an install silently unpinned.

The whole feature stays inert on main until the full RFC v1 (this
lock-file stack plus the later Sigstore stack) has landed — gated
behind TOOLHIVE_SKILLS_LOCK_ENABLED, following the existing
TOOLHIVE_DEV precedent for staged rollouts. Each PR in the stack is
independently mergeable without exposing partial behavior.

- Install hooks into the single choke point (installAndRegister):
  compute contentDigest, upsert the lock entry, roll back the DB
  record if the lock write fails
- toolhive.requires dependencies materialize recursively (visited
  set guards cycles, skills.MaxDependencies bounds the whole tree),
  with RequiredBy merged across shared dependencies
- Uninstall cascades to orphaned, non-explicit dependencies via
  Lockfile.RemoveParentFromRequiredBy, itself cycle-safe
- E2E coverage for the real HTTP API + thv serve subprocess path

Part of the production stack for #5715 (RFC THV-0080). Stack: 3/6.
gitresolver.ParseGitReference only uses "http://" when
TOOLHIVE_DEV=true; the correct default (and what GitHub Actions
CI runners use, since they don't set that var) is "https://".
The gitRef test helper hardcoded "http://" for the fixture
registration key, so every test using it failed in CI with
"no fixture registered for https://...". Compute the scheme the
same way the resolver does instead of hardcoding either one.
installAndRegister's rollback only deleted the DB record, so a
dependency-materialization failure after the parent's own lock entry
had already been written left toolhive.lock.yaml claiming a pin that
Info() couldn't find. Also key materializeDependencies' cycle-check
visited set by the reference a skill was installed with instead of
its resolved name, matching how dependency edges reference it — the
mismatch let a cycle through unresolved names re-materialize a skill
as its own dependency and corrupt its RequiredBy list.
Three panel-review findings on the install/uninstall lifecycle:
rollback deleted only the top-level skill, leaking earlier-succeeded
sibling dependencies as managed orphans whose RequiredBy pointed at a
rolled-back parent; rollback was also unconditionally destructive, so
a --force reinstall of a valid, lock-managed skill that failed on a
new dependency destroyed its pre-existing DB record and lock entry;
and uninstall removed the lock entry last and best-effort, so a
lock-write failure after files and DB were gone left a stale entry
the next sync silently reinstalled.

Rollback now snapshots pre-install state (DB record via
InstallResult.PreExisting, lock entry before any write) and restores
it when the record pre-existed; when this call created it, removal
runs the same dependency cascade as uninstall so fresh orphans are
cleaned while shared or explicit deps survive. Uninstall removes the
lock entry first and aborts intact on failure. Materialized
dependencies also now join the parent's --group instead of falling
back to the default group.
@samuv
samuv force-pushed the skills-lock/03-install-hooks branch from bad7ade to 187a35d Compare July 24, 2026 08:35
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@samuv
samuv merged commit 669265e into main Jul 24, 2026
48 checks passed
@samuv
samuv deleted the skills-lock/03-install-hooks branch July 24, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants