fix: third-party capability skills surface correctly (#2045)

A skills-only role:feature third-party capability installed 'active' but its skills never reached the runtime surface, capability enable/set rejected it as 'unknown capability', and capability list disagreed with capability state. Three defects, fix shape 1b (teach resolveSurface, no on-disk linking):

D1 (materialization): resolveSurface built the surfaced skills Set only from the on-disk manifest; third-party cap skills live at ~/.gsd/capabilities/<id>/skills/ and never entered the Set -> surfaced:false. Fix: union registry.capabilityClusters values into the Set in the full-profile branch (idempotent for first-party, additive for third-party; prototype-pollution guarded).

D2 (enable/set unknown): setCapabilityState validated the capId against the static first-party registry instead of the composed overlay-aware registry. Fix: validate against loadRegistry({includeInstalled}) like capability-state.cts does.

D3 (list vs state): capability list derived status purely from ledger existence, never surface composition. Fix: add a 'surfaced' field to list rows sourced from the same resolver capability state uses, so list and state agree.

Regression coverage: tests/issue-2045-third-party-skills-surface.test.cjs asserts all five acceptance criteria (resolveSurface union, state surfaced:true, enable/set not-unknown, list/state agreement, first-party + unknown-id regression). gsd-test: 23916/23916 green (linux-node22 + linux-node24).
This commit is contained in:
Tom Boucher
2026-07-07 08:11:44 -04:00
parent 2372a221eb
commit 847de596b8
21 changed files with 352 additions and 17 deletions

View File

@@ -151,8 +151,17 @@ function setCapabilityState(
const resolvedConfigDir = before.runtimeConfigDir;
// ── Load registry ─────────────────────────────────────────────────────────
// Issue #2045 (DEFECT 2): validate against the COMPOSED overlay-aware registry
// (first-party ∪ accepted overlays), mirroring capability-state.cts:547-551.
// The frozen capability-registry.cjs only knows first-party ids, so a third-
// party cap failed the membership check below → "unknown capability" even
// though resolveCapabilityRuntimeState (the `before` snapshot, line 150) already
// knew about it. loadRegistry is non-throwing and first-party-wins, so a
// malformed overlay is skipped (never crashes the writer); a truly-unknown id
// is STILL rejected because it is absent from the composed capabilities map.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
const capabilitiesMap = (
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
? registry['capabilities']

View File

@@ -261,6 +261,33 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
for (const [key] of skillManifest) {
if (!key.startsWith('_calls_agents_')) skills.add(key);
}
// Issue #2045 (DEFECT 1): third-party capability skills live at
// ~/.gsd/capabilities/<id>/skills/<stem>/SKILL.md — NOT in the runtime skills
// dir → never in skillManifest → never in the Set → surfaced:false. The
// overlay-aware registry's `capabilityClusters` already covers accepted
// overlay caps (composed by loadRegistry({includeInstalled})), so union its
// values into the surfaced Set. This is IDEMPOTENT for first-party skills
// (their stems are already on disk → already in the Set) and ADDITIVE for
// third-party skills (the fix). The 'full' profile means everything, and
// every cap's profileMembership profiles-array includes 'full' (it is the
// suffix top), so no per-tier gate is needed here — this invariant is owned
// by gen-capability-registry.cjs deriveProfileMembership (PROFILE_RANK suffix)
// + deriveCapabilityClusters (same non-empty-skills scoping); revisit both if
// either derivation changes. Prototype-pollution guard mirrors the cluster-
// merge block above (lines 217-240). NOTE: third-party cap agents are NOT
// unioned here (skillManifest has no `_calls_agents_` companion for them) —
// v1 scopes to skills-only caps per issue #2045; agents are a follow-up.
if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
const BANNED = ['__proto__', 'constructor', 'prototype'];
for (const capId of Object.keys(registry.capabilityClusters)) {
if (BANNED.includes(capId)) continue;
const stems = (registry.capabilityClusters as Record<string, unknown>)[capId];
if (!Array.isArray(stems)) continue;
for (const s of stems) {
if (typeof s === 'string' && s.length > 0) skills.add(s);
}
}
}
} else {
skills = new Set(baseResolved.skills);
}