diff --git a/.changeset/2362-opencode-kilo-capability-skill-materialize.md b/.changeset/2362-opencode-kilo-capability-skill-materialize.md new file mode 100644 index 000000000..209e2f43e --- /dev/null +++ b/.changeset/2362-opencode-kilo-capability-skill-materialize.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2434 +--- +**Installed third-party capability skills now materialize on OpenCode and Kilo** — `capability install` + `capability set --runtime opencode` (or `kilo`) could report a capability as `installed: true, surfaced: true, active: true` while its skill was never written to `skills/gsd-/SKILL.md`: the OpenCode/Kilo combined-family install path never called the seam #2322 fixed for other runtimes. Installed capability skills now materialize the same way there too, bound to their declaring capability, with first-party skills always winning a name collision. (#2362) diff --git a/src/install-engine.cts b/src/install-engine.cts index 34de44ade..1d7107559 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -29,6 +29,7 @@ import runtimeNamePolicy = require('./runtime-name-policy.cjs'); import installProfiles = require('./install-profiles.cjs'); import installerMigrations = require('./installer-migrations.cjs'); import { posixNormalize } from './shell-command-projection.cjs'; +import { isPathConfined } from './external-descriptor-trust.cjs'; const { processAttribution } = runtimeArtifactConversion; // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so @@ -625,7 +626,7 @@ function installRuntimeArtifacts( // _runLegacyInstallMigrations below entirely (early return), so their // legacy-directory cleanup needs its own pre-materialization hook here. _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors); - installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors); + installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry); return; } @@ -760,6 +761,17 @@ function installRuntimeArtifacts( * @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output) * @param pathPrefix - computed config-path prefix for body rewrites * @param resolveAttribution - injection: (runtime) => attribution string | undefined + * @param resolvedProfile - #2362: from resolveProfile()/resolveEffectiveProfile(); only + * `.skills` is consulted (either the `'*'` full-profile sentinel or a concrete Set + * of stems), and only to gate which THIRD-PARTY capability stems are candidates for + * staging below. Absent -> no third-party skills staged (fail closed). + * @param capabilityRegistry - #2362: optional composed capability registry + * (capabilityClusters view). When present, installed third-party capability + * skills bound to their declaring capId are unioned into the staged output — + * the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills) + * this bespoke OpenCode/Kilo writer never called. Absent -> no third-party + * skills staged (fail closed), matching the seam's own optional-registry + * contract. * @returns number of gsd-* skill directories written */ function installOpencodeFamilySkills( @@ -768,6 +780,8 @@ function installOpencodeFamilySkills( rawCommandsDir: string, pathPrefix: string, resolveAttribution: ResolveAttribution = () => undefined, + resolvedProfile?: any, + capabilityRegistry?: any, ): number { const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir); const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills'); @@ -815,9 +829,11 @@ function installOpencodeFamilySkills( _removeGsdEntries(dest, skillsKindEntry); let count = 0; + const firstPartyStems = new Set(); for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); + firstPartyStems.add(stem); const skillName = `${skillsKindEntry.prefix}${stem}`; let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8'); content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); @@ -829,6 +845,50 @@ function installOpencodeFamilySkills( count++; } + // #2362: materialize installed THIRD-PARTY capability skills, bound to their + // DECLARING capability via the registry's capabilityClusters view — mirrors + // install-profiles.cts stageSkillsForRuntimeAsSkills's third-party fill-in + // (the actual #2322 seam), reusing its exported security-reviewed helpers + // rather than hand-rolling a second scan (DEFECT.GENERATIVE-FIX guard). + // First-party always wins on stem collision. The full/'*' sentinel resolves + // through capabilityClusterStems (BLOCKER-2 parity: `resolveProfile` + // short-circuits `full` to `'*'` before consulting a registry, so a bare + // `resolvedProfile.skills !== '*'` gate would silently skip this pass for + // the default full install). No registry in scope -> stage NOTHING + // third-party (fail closed — never fall back to scanning). + // + // Unlike the seam (which stages third-party bodies as-is and relies on a + // later applySurface rewrite pass), this install path has no such later + // pass — so third-party bodies get the SAME inline path-prefix/attribution + // rewrite as first-party ones for on-disk parity. They do NOT go through + // `converter`: an installed capability skill is already a complete + // SKILL.md, not a Claude-command body awaiting frontmatter conversion. + if (capabilityRegistry) { + const candidateStems: Iterable = + resolvedProfile && resolvedProfile.skills === '*' + ? installProfiles.capabilityClusterStems(capabilityRegistry) + : (resolvedProfile && resolvedProfile.skills) || []; + for (const stem of candidateStems) { + if (firstPartyStems.has(stem)) continue; // first-party always wins + const found = installProfiles.readInstalledCapabilitySkill(stem, capabilityRegistry); + if (found === null) continue; // absent/malformed/unowned -> skip gracefully + const skillName = `${skillsKindEntry.prefix}${stem}`; + if (!isPathConfined(skillName, dest)) continue; // defense-in-depth + let content = found.content; + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); + content = processAttribution(content, resolveAttribution(runtime)); + const skillDir = path.join(dest, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + // #2322 HIGH-3 parity: persist the capability-owned marker so a later + // prune pass can identify this directory even once the owning + // capability is uninstalled/unsurfaced and no longer appears in any + // registry view. + fs.writeFileSync(path.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8'); + count++; + } + } + // Restore user-owned dirs after the prune+copy. for (const [dirName, snap] of toPreserve) { _restoreDir(path.join(dest, dirName), snap); @@ -1029,6 +1089,11 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile() * @param resolveAttribution - injection: (runtime) => attribution string | undefined * @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller) + * @param capabilityRegistry - #2362: optional composed capability registry + * (capabilityClusters view), threaded straight through to + * installOpencodeFamilySkills so an installed third-party capability skill + * materializes for this combined-family (OpenCode/Kilo) install path too. + * Absent -> no third-party skills staged (fail closed). */ function installOpencodeFamilyArtifacts( runtime: string, @@ -1037,6 +1102,7 @@ function installOpencodeFamilyArtifacts( resolvedProfile: any, resolveAttribution: ResolveAttribution = () => undefined, behaviors: any = {}, + capabilityRegistry?: any, ): void { const isGlobal = scope === 'global'; // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir @@ -1068,7 +1134,7 @@ function installOpencodeFamilyArtifacts( behaviors.flatCommandDir || 'command', ); installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution); - installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution); + installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry); _installNativePluginIfDeclared(runtime, configDir, behaviors, src); } diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 728c49060..4cd504550 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -43,6 +43,7 @@ const { const { loadSkillsManifest, resolveProfile, + CAPABILITY_SKILL_MARKER, } = require('../gsd-core/bin/lib/install-profiles.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -466,6 +467,264 @@ describe('installOpencodeFamilySkills — emits skills//SKILL.md (#784)', } }); +// ─── #2362: OpenCode/Kilo combined-family INSTALL path drops the capability +// registry — an installed+registered+surfaced+active third-party capability +// skill never materializes ─────────────────────────────────────────────── +// +// installOpencodeFamilyArtifacts (called from installRuntimeArtifacts's +// combinedFamilyInstall early return) never threaded capabilityRegistry into +// installOpencodeFamilySkills, so the actual #2322 seam +// (install-profiles.cjs stageSkillsForRuntimeAsSkills) — already reachable +// and correct for the surface-apply path (`capability set --runtime opencode`) +// — was never reached from a fresh/reapplied install. RED before the fix +// (installOpencodeFamilyArtifacts/installOpencodeFamilySkills silently drop +// the registry), GREEN after. + +/** Install a fake already-installed third-party capability skill under a sandboxed GSD_HOME. */ +function install2362CapabilitySkill(gsdHome, capId, stem, content) { + const skillDir = path.join(gsdHome, '.gsd', 'capabilities', capId, 'skills', stem); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content, 'utf8'); +} + +/** + * A minimal capability-registry shape carrying one capId -> [stems] cluster. + * `tier` (optional) additionally sets profileMembership so resolveProfile() + * with a tiered (non-'*') mode unions this capability's stems into + * resolvedProfile.skills — mirrors the seam's own __registryFor helper in + * tests/runtime-artifact-layout-install-profiles.test.cjs. + */ +function registry2362For(capId, stems, tier) { + const registry = { capabilityClusters: { [capId]: stems } }; + if (tier) { + registry.profileMembership = { + [capId]: { tier, profiles: tier === 'core' ? ['core', 'standard', 'full'] : ['standard', 'full'] }, + }; + } + return registry; +} + +/** Run `fn` with GSD_HOME pointed at `gsdHome`, always restoring the prior value. */ +function with2362GsdHome(gsdHome, fn) { + const saved = process.env.GSD_HOME; + process.env.GSD_HOME = gsdHome; + try { + return fn(); + } finally { + if (saved === undefined) delete process.env.GSD_HOME; + else process.env.GSD_HOME = saved; + } +} + +/** Recursively snapshot every FILE under dir into a Map. */ +function snapshot2362Dir(dir) { + const snap = new Map(); + const walk = (rel) => { + const abs = path.join(dir, rel); + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const relChild = path.join(rel, entry.name); + if (entry.isDirectory()) walk(relChild); + else if (entry.isFile()) snap.set(relChild, fs.readFileSync(path.join(dir, relChild), 'utf8')); + } + }; + walk('.'); + return snap; +} + +/** Assert every entry captured in `before` (a snapshot2362Dir Map) still exists, byte-identical, under dir. */ +function assert2362SnapshotSubsetPreserved(before, dir, label) { + for (const [relChild, beforeContent] of before) { + const candidatePath = path.join(dir, relChild); + assert.ok(fs.existsSync(candidatePath), `${label}: ${relChild} must still exist`); + assert.strictEqual(fs.readFileSync(candidatePath, 'utf8'), beforeContent, `${label}: ${relChild} must be byte-identical`); + } +} + +for (const __runtime2362 of ['opencode', 'kilo']) { + describe(`installRuntimeArtifacts — #2362 ${__runtime2362} materializes an installed third-party capability skill`, () => { + test(`${__runtime2362}: (1) registered capability skill materializes at skills/gsd-/SKILL.md`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + const AUTHORED = '---\nname: my-thing\ndescription: third-party test skill\n---\n\n# My Thing\nThird-party capability content.\n'; + install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', AUTHORED); + const registry = registry2362For('my-thing', ['my-thing']); + const resolvedFull = resolveProfile({ modes: ['full'] }); + + with2362GsdHome(gsdHome, () => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry); + }); + + const stagedPath = path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md'); + assert.ok( + fs.existsSync(stagedPath), + `#2362: gsd-my-thing/SKILL.md must exist after installRuntimeArtifacts('${__runtime2362}', ...) — registry says surfaced:true but the combined-family install path never materialized it`, + ); + + // (2) prune parity: the staged directory carries the #2322 HIGH-3 marker + // naming its declaring capId, matching the seam's guarantee. + const markerPath = path.join(configDir, 'skills', 'gsd-my-thing', CAPABILITY_SKILL_MARKER); + assert.ok(fs.existsSync(markerPath), 'staged third-party skill must carry the CAPABILITY_SKILL_MARKER for prune parity'); + assert.strictEqual(fs.readFileSync(markerPath, 'utf8').trim(), 'my-thing', 'marker must name the declaring capId'); + }); + + test(`${__runtime2362}: (2) tiered (non-'*') profile — resolvedProfile.skills is a concrete Set and still materializes the third-party skill`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-tiered-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing (tiered)\n'); + // tier='standard' -> profileMembership includes 'standard', so + // resolveProfile({modes:['standard'], registry}) unions 'my-thing' into + // the resolved Set BEFORE it ever reaches installOpencodeFamilySkills — + // exercising the `(resolvedProfile && resolvedProfile.skills) || []` + // (non-'*') branch of the new candidateStems ternary, never covered by + // the mode=['full'] cases above. + const registry = registry2362For('my-thing', ['my-thing'], 'standard'); + const resolvedTiered = resolveProfile({ modes: ['standard'], registry }); + assert.ok( + resolvedTiered.skills instanceof Set, + "sanity: a tiered (non-'*') profile mode must resolve to a concrete Set, not the full sentinel", + ); + assert.ok( + resolvedTiered.skills.has('my-thing'), + 'sanity: resolveProfile must union the registered capability skill into the tiered profile\'s Set (mirrors production callers)', + ); + + with2362GsdHome(gsdHome, () => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedTiered, () => undefined, registry); + }); + + const stagedPath = path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md'); + assert.ok( + fs.existsSync(stagedPath), + `#2362: a tiered profile (mode=standard) must also materialize a registered third-party capability skill via the non-'*' candidateStems branch`, + ); + const markerPath = path.join(configDir, 'skills', 'gsd-my-thing', CAPABILITY_SKILL_MARKER); + assert.ok(fs.existsSync(markerPath), 'staged third-party skill must carry the CAPABILITY_SKILL_MARKER for prune parity even on a tiered profile'); + assert.strictEqual(fs.readFileSync(markerPath, 'utf8').trim(), 'my-thing', 'marker must name the declaring capId'); + + // Sanity: first-party skills that ARE part of the 'standard' base list + // (e.g. 'help') must still stage too — the third-party fill-in must not + // replace or crowd out the tiered profile's own first-party selection. + assert.ok( + fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')), + "sanity: first-party 'standard' base skills must still stage alongside the third-party fill-in", + ); + }); + + test(`${__runtime2362}: (3) default full profile — resolvedProfile.skills === '*' still materializes the third-party skill (BLOCKER-2 parity)`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-full-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing (full)\n'); + const registry = registry2362For('my-thing', ['my-thing']); + const resolvedFull = resolveProfile({ modes: ['full'] }); + assert.strictEqual(resolvedFull.skills, '*', "sanity: full mode resolves to the '*' sentinel"); + + with2362GsdHome(gsdHome, () => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry); + }); + + assert.ok( + fs.existsSync(path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md')), + "BLOCKER-2 parity: the '*' full-profile sentinel must still stage a registered capability skill", + ); + }); + + test(`${__runtime2362}: (4) first-party wins on a stem collision`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-collide-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + const EVIL = '# EVIL HELP — must never win over the first-party gsd-help skill\n'; + install2362CapabilitySkill(gsdHome, 'evil-cap', 'help', EVIL); + const registry = registry2362For('evil-cap', ['help']); + const resolvedFull = resolveProfile({ modes: ['full'] }); + + with2362GsdHome(gsdHome, () => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry); + }); + + const helpPath = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(helpPath), 'sanity: first-party gsd-help must stage'); + const helpContent = fs.readFileSync(helpPath, 'utf8'); + assert.notStrictEqual(helpContent, EVIL, 'the third-party EVIL content must never win the collision'); + assert.ok(!helpContent.includes('EVIL HELP'), 'first-party content must be staged on a stem collision, not the third-party body'); + }); + + test(`${__runtime2362}: (5) graceful degradation — an unreadable capability SKILL.md does not throw and first-party staging is unaffected`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-degrade-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing\n'); + const registry = registry2362For('my-thing', ['my-thing']); + const resolvedFull = resolveProfile({ modes: ['full'] }); + + // Inject the IO failure deterministically by monkeypatching fs, scoped to + // this one capability's SKILL.md path — never chmod 0o000 (root bypasses + // mode bits; silently zero-coverage under root Docker/CI). + const skillPath = path.join(gsdHome, '.gsd', 'capabilities', 'my-thing', 'skills', 'my-thing', 'SKILL.md'); + const origReadFileSync = fs.readFileSync; + fs.readFileSync = function injectedReadFileSync(p, ...rest) { + if (p === skillPath) throw new Error('injected read failure (#2362 regression test)'); + return origReadFileSync.call(fs, p, ...rest); + }; + try { + with2362GsdHome(gsdHome, () => { + assert.doesNotThrow(() => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry); + }, `installRuntimeArtifacts('${__runtime2362}', ...) must not throw when a registry-referenced capability skill is unreadable`); + }); + } finally { + fs.readFileSync = origReadFileSync; + } + + assert.ok( + !fs.existsSync(path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md')), + 'an unreadable capability skill must be skipped, not partially staged', + ); + assert.ok( + fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')), + 'first-party skills must still materialize when a referenced capability skill is unreadable', + ); + }); + + test(`${__runtime2362}: (6) control — first-party skills are unperturbed (byte-identical) by an installed third-party capability skill`, (t) => { + const gsdHome = createTempDir('gsd-2362-home-'); + const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-control-`); + t.after(() => { cleanup(gsdHome); cleanup(configDir); }); + + const resolvedFull = resolveProfile({ modes: ['full'] }); + + // Baseline: no registry threaded through at all. + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, undefined); + const skillsDir = path.join(configDir, 'skills'); + const baselineSnapshot = snapshot2362Dir(skillsDir); + assert.ok(baselineSnapshot.size > 0, 'sanity: baseline install staged at least one first-party skill'); + assert.ok(!baselineSnapshot.has(path.join('gsd-my-thing', 'SKILL.md')), 'sanity: baseline has no gsd-my-thing'); + + // Re-apply to the SAME configDir once a third-party capability is also + // registered+installed — rebuilds nothing else, so any first-party byte + // drift here is attributable only to the third-party fill-in pass. + install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing\n'); + const registry = registry2362For('my-thing', ['my-thing']); + with2362GsdHome(gsdHome, () => { + installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry); + }); + + assert2362SnapshotSubsetPreserved(baselineSnapshot, skillsDir, `${__runtime2362} first-party skills`); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-my-thing', 'SKILL.md')), + 'the third-party capability skill must ALSO be present after the re-apply', + ); + }); + }); +} + // ─── Section 7: uninstallRuntimeArtifacts — all runtimes ───────────────────── describe('uninstallRuntimeArtifacts — consumes Runtime Artifact Uninstall Plan Module', () => {