* test(#2322): fail-first tests for third-party capability skill materialization Red phase: tests (1) and (6) fail — resolveSurface reports the third-party stem surfaced (#2045) but no SKILL.md is ever written to disk. The other four are controls that must keep holding: first-party-wins collision, profile-tier filter, nested-router layout unperturbed, and absent/malformed capability must not throw. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * fix(#2322): materialize installed third-party capability skills A capability could report installed:true, surfaced:true, active:true and still never exist as an invocable command. #2045 fixed the registry layer — resolveSurface unions registry.capabilityClusters into the resolved skill set — but the materialization layer never got the matching fix. stageSkillsForRuntimeAsSkills only ever read gsd-core's own bundled commands/gsd/*.md and silently skipped any stem it couldn't find there, so a third-party skill living at <GSD_HOME>/.gsd/capabilities/<id>/skills/<stem>/ was never copied. Registry said surfaced; disk had nothing. Installed capability skills are now staged alongside the first-party ones, copied verbatim (they are authored complete for their target runtime and need no converter). First-party stems always win a collision, the profile filter still applies, and an absent or malformed capability degrades rather than throwing. Security: capability.json's skills[] entries are validated only as non-empty non-reserved strings (capability-validator.cjs:503-514) — no path shape is enforced upstream — so stems are sanitized (rejecting separators, '..', absolute paths, NUL) with an independent isPathConfined check on both the read and write paths. A '../../evil' stem writes nothing outside the capability's own dir. Also fixes a defect this surfaced in pruneSkillDirs: a materialized capability skill dir has no first-party manifest entry, so every apply logged "preserving (user-owned or unknown)" for a live GSD-managed dir. The retained check now precedes the manifest gate; no deletion outcome changes, and genuinely unknown gsd-* dirs still warn and are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * fix(#2322): address security review — bind skills to declaring capability, fix full profile An independent security review BLOCKED the first pass. Both blockers were mine. BLOCKER 1 (security): readInstalledCapabilitySkill scanned every capability dir and returned the first sorted match, never checking that a capability DECLARES the stem — ownership was inferred from attacker-controlled filesystem layout. Since install copies the whole bundle and the validator only checks DECLARED entries, a capability declaring `skills: []` could ship an undeclared skills/deploy/SKILL.md and win the `deploy` stem on sort order, supplying the agent-invocable instructions the user believed came from the registered capability. Stems are now bound to their owning capId via registry.capabilityClusters, and only that capability's dir is read. BLOCKER 2: the fill-in pass was gated `skills !== '*'` on the premise that applySurface materializes `full` into a concrete Set. True for applySurface — false for the installer, which is the default path: resolveProfile returns the '*' sentinel and bin/install.js passes it straight to staging. So #2322 survived on the default `full` profile, i.e. the fix didn't fix the reported bug. The registry is now plumbed to staging, and '*' stages all capability-cluster stems. Wiring this surfaced a second gap: the ADR-1239 imperative adapter (the primary install path) never threaded its registry either, which would have silently defeated the fix on the real default install. HIGH: staged capability skills were never prunable — pruneSkillDirs gates on the first-party manifest, so uninstalling a capability left its instructions live in the agent's context forever. Staged skills now carry a marker making them GSD-owned and prunable; genuinely unknown gsd-* dirs still warn and are preserved. MEDIUM: the "staged verbatim" claim was false — applySurface rewrites bodies over the whole stage dir. The tests asserted byte-equality and passed only because their fixtures contained no rewrite triggers. Claim dropped; tests now assert the rewrite against triggering content. LOW: isPathConfined is lexical, not realpath (symlink-defeatable, currently unreachable because install rejects symlinks) — comment corrected. The validator does not enforce non-empty, so isSafeCapabilitySkillStem is the sole defense, not a second layer — comment corrected and it now has traversal/NUL/absolute/empty test coverage (previously mutating it to `return true` left every test green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * test(#2322): pin that the imperative adapter forwards a capability registry The delegation-args test deep-equalled the exact argv to installRuntimeArtifacts, so threading the composed capability registry through the ADR-1239 imperative adapter (required for #2322 — without it the default `full` install path never materializes third-party capability skills) failed it. The contract legitimately gained a parameter, so this is a stale-test correction, not a regression. Rather than deep-equalling the whole composed registry (brittle — it embeds the full agent/profile map), the test pins the leading args exactly and asserts only that a registry-shaped value is forwarded. That still fails if the adapter stops threading it, which is the regression the test exists to catch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * docs(#2322): backfill PR number 2340 into changeset Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
5.9 KiB
JavaScript
113 lines
5.9 KiB
JavaScript
'use strict';
|
||
/**
|
||
* Tests for the imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680).
|
||
*
|
||
* Pins:
|
||
* 1. KIND — `kind: 'imperative'`, satisfies the same HostIntegrationInterface
|
||
* as the declarative adapter (both bind one engine).
|
||
* 2. REGISTRY COMPOSITION — the adapter composes loadRegistry({includeInstalled:
|
||
* true}) and exposes the result as `.registry` (first-party ∪ installed, so
|
||
* an in-process host gets identical trust semantics to the CLI).
|
||
* 3. DELEGATION — install/uninstall delegate in-process to install-engine
|
||
* (byte-identity link, same as the declarative adapter).
|
||
* 4. FAIL-CLOSED CONSTRUCTION — missing/invalid runtime throws.
|
||
*
|
||
* Behavioral tests only; delegation + loadRegistry verified via module-ref
|
||
* monkeypatch (Node module cache shares the one module object).
|
||
*/
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||
const installEngine = require('../gsd-core/bin/lib/install-engine.cjs');
|
||
const capabilityLoader = require('../gsd-core/bin/lib/capability-loader.cjs');
|
||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||
|
||
const RUNTIMES = Object.keys(registry.runtimes);
|
||
|
||
test('imperative adapter: kind === "imperative" + runtime echoed + registry present, for every registry runtime', () => {
|
||
for (const r of RUNTIMES) {
|
||
const adapter = createImperativeAdapter({ runtime: r });
|
||
assert.strictEqual(adapter.kind, 'imperative', `${r}: kind must be 'imperative'`);
|
||
assert.strictEqual(adapter.runtime, r, `${r}: adapter must echo the runtime id`);
|
||
assert.ok(adapter.registry && typeof adapter.registry === 'object', `${r}: registry must be present (composed loadRegistry result)`);
|
||
assert.strictEqual(typeof adapter.install, 'function', `${r}: install must be a function`);
|
||
assert.strictEqual(typeof adapter.uninstall, 'function', `${r}: uninstall must be a function`);
|
||
}
|
||
});
|
||
|
||
test('imperative adapter: loadRegistry composed with includeInstalled:true (host gets CLI-equivalent trust semantics)', () => {
|
||
// Restore-able spy on capabilityLoader.loadRegistry (module-ref, shared via Node cache).
|
||
const original = capabilityLoader.loadRegistry;
|
||
const calls = [];
|
||
capabilityLoader.loadRegistry = function (opts) {
|
||
calls.push(opts);
|
||
// Return a minimal stand-in registry so the factory does not crash.
|
||
return { _spy: true, runtimes: registry.runtimes };
|
||
};
|
||
try {
|
||
const adapter = createImperativeAdapter({ runtime: 'opencode' });
|
||
assert.ok(calls.length >= 1, 'loadRegistry must be invoked once during construction');
|
||
assert.strictEqual(calls[0].includeInstalled, true, 'loadRegistry must be called with includeInstalled:true (compose first-party ∪ installed)');
|
||
assert.strictEqual(adapter.registry._spy, true, 'adapter.registry must expose the composed loadRegistry result');
|
||
} finally {
|
||
capabilityLoader.loadRegistry = original;
|
||
}
|
||
});
|
||
|
||
test('imperative adapter: loadOptions forwarded to loadRegistry (cwd/gsdHome/hostVersion pass-through)', () => {
|
||
const original = capabilityLoader.loadRegistry;
|
||
let captured = null;
|
||
capabilityLoader.loadRegistry = function (opts) { captured = opts; return { runtimes: registry.runtimes }; };
|
||
try {
|
||
createImperativeAdapter({ runtime: 'codex' }, { loadOptions: { cwd: '/tmp/proj', hostVersion: '1.7.0' } });
|
||
assert.strictEqual(captured.includeInstalled, true, 'includeInstalled default preserved');
|
||
assert.strictEqual(captured.cwd, '/tmp/proj', 'loadOptions.cwd forwarded');
|
||
assert.strictEqual(captured.hostVersion, '1.7.0', 'loadOptions.hostVersion forwarded');
|
||
} finally {
|
||
capabilityLoader.loadRegistry = original;
|
||
}
|
||
});
|
||
|
||
test('imperative adapter.install/uninstall delegate in-process to install-engine with exact args', () => {
|
||
const origInstall = installEngine.installRuntimeArtifacts;
|
||
const origUninstall = installEngine.uninstallRuntimeArtifacts;
|
||
try {
|
||
for (const r of RUNTIMES) {
|
||
const adapter = createImperativeAdapter({ runtime: r });
|
||
let installArgs = null;
|
||
let uninstallArgs = null;
|
||
installEngine.installRuntimeArtifacts = function (...a) { installArgs = a; return undefined; };
|
||
installEngine.uninstallRuntimeArtifacts = function (...a) { uninstallArgs = a; return undefined; };
|
||
adapter.install({ configDir: '/tmp/imp/' + r, scope: 'global', resolvedProfile: { p: 1 } });
|
||
adapter.uninstall({ configDir: '/tmp/imp/' + r, scope: 'local' });
|
||
// The trailing arg is the composed capability registry (#2322): the adapter
|
||
// must forward one, or third-party capability skills never materialize on
|
||
// the default `full` install path. Its contents are the loader's business —
|
||
// pin only that a registry-shaped value is threaded through, not its bulk.
|
||
const [, , , , manifest, registry] = installArgs;
|
||
assert.deepStrictEqual(
|
||
installArgs.slice(0, 5),
|
||
[r, '/tmp/imp/' + r, 'global', { p: 1 }, undefined],
|
||
`${r}: install delegation args`,
|
||
);
|
||
assert.strictEqual(manifest, undefined, `${r}: manifest arg unchanged`);
|
||
assert.ok(
|
||
registry && typeof registry === 'object' && 'capabilityClusters' in registry,
|
||
`${r}: install must forward a composed capability registry (#2322), got: ${typeof registry}`,
|
||
);
|
||
assert.deepStrictEqual(uninstallArgs, [r, '/tmp/imp/' + r, 'local'], `${r}: uninstall delegation args`);
|
||
}
|
||
} finally {
|
||
installEngine.installRuntimeArtifacts = origInstall;
|
||
installEngine.uninstallRuntimeArtifacts = origUninstall;
|
||
}
|
||
});
|
||
|
||
test('createImperativeAdapter: throws on missing/invalid runtime (fail-closed construction)', () => {
|
||
for (const bad of ['', undefined, null]) {
|
||
assert.throws(() => createImperativeAdapter({ runtime: bad }), TypeError, `runtime=${JSON.stringify(bad)} must throw`);
|
||
}
|
||
assert.throws(() => createImperativeAdapter({}), TypeError, 'missing runtime must throw');
|
||
});
|