ADR-1244 Phase 4 (D5 trust + D6 upgrade/compat). capability-trust.cjs (disclosure/consent, strict_known_registries, engines+compatVersions, reserved namespace) + capability-lifecycle.cjs (install/upgrade/remove/reconcile; ledger-as-commit-point _pending intent; atomic stage-then-swap; surgical marker-isolated shared-edit strip; owner-token lock) + capability-source promote/skipEnginesGate seams + loader pending-skip + config keys. No sandbox re-derived (consent+integrity+reversibility). 6 Codex adversarial rounds + /security-review (no HIGH) + /code-review; gsd-test green both platforms; CI green. Phase 5 (#1434) wires the CLI dispatch. Closes #1433. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
628 lines
31 KiB
JavaScript
628 lines
31 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for capability lifecycle orchestration — ADR-1244 Phase 4 (D5 + D6).
|
|
* Covers install (consent / abort / block), upgrade (atomic stage-then-swap, ledger commit
|
|
* point, executable-set re-consent), remove (surgical marker-isolated strip + the user
|
|
* hand-edit fault case), and the crash-recovery reconciliation sweep.
|
|
*
|
|
* The bulk of the logic is exercised through an injectable `_resolve` seam (so tests are not
|
|
* coupled to full capability validation); one test drives the REAL resolver end-to-end.
|
|
*/
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const lifecycle = require('../gsd-core/bin/lib/capability-lifecycle.cjs');
|
|
const ledgerMod = require('../gsd-core/bin/lib/capability-ledger.cjs');
|
|
const { CAP_MARKER } = lifecycle;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const cleanups = [];
|
|
function runtime() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-life-'));
|
|
cleanups.push(dir);
|
|
return dir;
|
|
}
|
|
test.after(() => {
|
|
for (const d of cleanups) cleanup(d);
|
|
});
|
|
|
|
function declarativeCap(id, version = '1.0.0') {
|
|
return {
|
|
id,
|
|
role: 'feature',
|
|
version,
|
|
title: id,
|
|
description: 'test capability',
|
|
tier: 'standard',
|
|
requires: [],
|
|
engines: { gsd: '>=1.0.0' },
|
|
runtimeCompat: { supported: ['*'], unsupported: [] },
|
|
skills: [],
|
|
agents: [],
|
|
hooks: [],
|
|
config: {},
|
|
steps: [],
|
|
contributions: [],
|
|
gates: [],
|
|
};
|
|
}
|
|
|
|
function execCap(id, version, { script = 'hooks/run.js', mcp = null } = {}) {
|
|
const cap = declarativeCap(id, version);
|
|
cap.hooks = [{ event: 'PostToolUse', script }];
|
|
if (mcp) cap.mcpServers = mcp;
|
|
return cap;
|
|
}
|
|
|
|
let stageCounter = 0;
|
|
/** Materialize a declared artifact file inside the staged bundle (so the trust gate's
|
|
* existence check passes), skipping absolute/traversal paths. */
|
|
function materialize(dir, rel) {
|
|
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes('..')) return;
|
|
const p = path.join(dir, rel);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, '// artifact', 'utf8');
|
|
}
|
|
/** Build a `_resolve` seam that stages `manifest` (and its declared artifacts) under .staging. */
|
|
function fakeResolve(manifest, { integrity = null, throwErr = null } = {}) {
|
|
return async (spec, opts) => {
|
|
if (throwErr) throw new Error(throwErr);
|
|
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
|
|
fs.mkdirSync(root, { recursive: true });
|
|
const dir = path.join(root, `${manifest.id}-${++stageCounter}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(manifest), 'utf8');
|
|
for (const h of manifest.hooks || []) if (h && h.script) materialize(dir, h.script);
|
|
for (const c of manifest.commands || []) if (c && c.module) materialize(dir, c.module);
|
|
return { id: manifest.id, version: manifest.version, stagedDir: dir, integrity, source: spec };
|
|
};
|
|
}
|
|
|
|
function readLedgerEntry(dir, id) {
|
|
const l = ledgerMod.readLedger(dir);
|
|
return l && l.entries[id] ? l.entries[id] : null;
|
|
}
|
|
function readSettings(dir) {
|
|
try { return JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); } catch { return null; }
|
|
}
|
|
function capManifestVersion(dir, id) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(path.join(dir, '.gsd', 'capabilities', id, 'capability.json'), 'utf8')).version;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('install: declarative capability installs without consent and records the ledger', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.installCapability('./decl', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('decl')),
|
|
});
|
|
assert.strictEqual(res.status, 'installed');
|
|
const entry = readLedgerEntry(dir, 'decl');
|
|
assert.ok(entry, 'ledger entry recorded');
|
|
assert.strictEqual(entry.version, '1.0.0');
|
|
assert.ok(fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'decl', 'capability.json')));
|
|
});
|
|
|
|
test('install: executable capability without consent aborts and writes NOTHING', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.installCapability('./exec', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: false, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('exec', '1.0.0')),
|
|
});
|
|
assert.strictEqual(res.status, 'aborted');
|
|
assert.strictEqual(res.requiresConsent, true);
|
|
assert.strictEqual(readLedgerEntry(dir, 'exec'), null, 'no ledger entry');
|
|
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'exec')), 'no install dir');
|
|
assert.strictEqual(readSettings(dir), null, 'no settings.json written');
|
|
});
|
|
|
|
test('install: executable capability with consent installs and applies marked shared edits', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.installCapability('./exec', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('exec', '1.0.0', { mcp: { 'cap-srv': { command: 'node' } } })),
|
|
});
|
|
assert.strictEqual(res.status, 'installed');
|
|
const settings = readSettings(dir);
|
|
assert.ok(settings.hooks.PostToolUse.length === 1);
|
|
assert.strictEqual(settings.hooks.PostToolUse[0][CAP_MARKER], 'exec');
|
|
assert.strictEqual(settings.mcpServers['cap-srv'][CAP_MARKER], 'exec');
|
|
const entry = readLedgerEntry(dir, 'exec');
|
|
assert.deepStrictEqual(entry.sharedEdits, [{ file: 'settings.json', marker: 'exec' }]);
|
|
});
|
|
|
|
test('install: a disallowed source is blocked BEFORE the resolver runs', async () => {
|
|
const dir = runtime();
|
|
let resolverCalled = false;
|
|
const res = await lifecycle.installCapability('https://github.com/x/y.git', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', strictKnownRegistries: [],
|
|
_resolve: async () => { resolverCalled = true; throw new Error('should not run'); },
|
|
});
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.strictEqual(resolverCalled, false, 'resolver must not be invoked for a blocked source');
|
|
assert.strictEqual(readLedgerEntry(dir, 'y'), null);
|
|
});
|
|
|
|
test('install: a reserved-namespace capability is blocked', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.installCapability('./x', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('gsd-evil')),
|
|
});
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.ok(res.blockReasons.some((r) => /reserved namespace/.test(r)));
|
|
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'gsd-evil')));
|
|
});
|
|
|
|
test('install: engines mismatch is blocked at install WITH a compatVersions downgrade hint', async () => {
|
|
const dir = runtime();
|
|
const cap = declarativeCap('eng', '3.0.0');
|
|
cap.engines = { gsd: '>=2.0.0' };
|
|
cap.compatVersions = { '1.4.0': '>=1.5.0 <2.0.0' };
|
|
const res = await lifecycle.installCapability('./eng', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(cap),
|
|
});
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.ok(res.blockReasons.some((r) => /compatVersions offers 1\.4\.0/.test(r)), JSON.stringify(res.blockReasons));
|
|
assert.strictEqual(readLedgerEntry(dir, 'eng'), null);
|
|
});
|
|
|
|
test('integration: engines-incompatible local cap is blocked by the lifecycle, not the resolver throw (skipEnginesGate)', async () => {
|
|
const dir = runtime();
|
|
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-src-eng-'));
|
|
cleanups.push(src);
|
|
const cap = declarativeCap('engreal');
|
|
cap.engines = { gsd: '>=99.0.0' };
|
|
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap), 'utf8');
|
|
const res = await lifecycle.installCapability(src, { runtimeDir: dir, hostVersion: '1.6.0' });
|
|
assert.strictEqual(res.status, 'blocked', JSON.stringify(res));
|
|
assert.ok(res.blockReasons.some((r) => /engines\.gsd/.test(r)));
|
|
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'engreal')), 'nothing installed');
|
|
const staging = path.join(dir, '.gsd', 'capabilities', '.staging');
|
|
assert.deepStrictEqual(fs.existsSync(staging) ? fs.readdirSync(staging) : [], [], 'staging cleaned');
|
|
});
|
|
|
|
test('install: re-installing over an existing capability (via install, not upgrade) advances the bundle + ledger, no backup lingers', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0')),
|
|
});
|
|
const res = await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '2.0.0')),
|
|
});
|
|
assert.strictEqual(res.status, 'installed');
|
|
assert.strictEqual(capManifestVersion(dir, 'e'), '2.0.0');
|
|
assert.strictEqual(readLedgerEntry(dir, 'e').version, '2.0.0');
|
|
assert.ok(!readLedgerEntry(dir, 'e')._pending, 'intent cleared on commit');
|
|
assert.deepStrictEqual(fs.readdirSync(path.join(dir, '.gsd', 'capabilities')).filter((n) => n.includes('.upgrading-')), [], 'no backup left');
|
|
assert.strictEqual(readSettings(dir).hooks.PostToolUse.length, 1, 'exactly one stamped hook');
|
|
});
|
|
|
|
test('install: a stale lock is stolen so a crashed prior holder does not block forever', async () => {
|
|
const dir = runtime();
|
|
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
fs.writeFileSync(lockPath, 'dead-holder-token', 'utf8');
|
|
// Backdate the lock well past the stale threshold (simulate a crashed holder).
|
|
const old = new Date(Date.now() - 5 * 60 * 1000);
|
|
fs.utimesSync(lockPath, old, old);
|
|
const res = await lifecycle.installCapability('./x', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('x')),
|
|
});
|
|
assert.strictEqual(res.status, 'installed', 'stale lock stolen, install proceeds');
|
|
});
|
|
|
|
test('install: a resolver failure (e.g. integrity mismatch) is reported as blocked', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.installCapability('./x', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('x'), { throwErr: 'Integrity mismatch: expected ... got ...' }),
|
|
});
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.ok(res.blockReasons.some((r) => /Integrity mismatch/.test(r)));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Upgrade
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('upgrade: a not-installed capability cannot be upgraded', async () => {
|
|
const dir = runtime();
|
|
const res = await lifecycle.upgradeCapability('./x', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('x', '2.0.0')),
|
|
});
|
|
assert.strictEqual(res.status, 'not_installed');
|
|
});
|
|
|
|
test('upgrade: same executable set upgrades without re-consent; bundle + ledger advance, no backup left', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0')),
|
|
});
|
|
const res = await lifecycle.upgradeCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: false, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '2.0.0')), // same hook script => same exec set
|
|
});
|
|
assert.strictEqual(res.status, 'upgraded');
|
|
assert.strictEqual(res.fromVersion, '1.0.0');
|
|
assert.strictEqual(res.toVersion, '2.0.0');
|
|
assert.strictEqual(capManifestVersion(dir, 'e'), '2.0.0');
|
|
assert.strictEqual(readLedgerEntry(dir, 'e').version, '2.0.0');
|
|
const leftovers = fs.readdirSync(path.join(dir, '.gsd', 'capabilities')).filter((n) => n.includes('.upgrading-'));
|
|
assert.deepStrictEqual(leftovers, [], 'no backup dir left behind');
|
|
// Exactly one stamped hook remains (old stripped, new applied).
|
|
assert.strictEqual(readSettings(dir).hooks.PostToolUse.length, 1);
|
|
});
|
|
|
|
test('upgrade: a changed executable set without consent aborts and leaves the OLD version fully intact', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/a.js' })),
|
|
});
|
|
const res = await lifecycle.upgradeCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: false, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '2.0.0', { script: 'hooks/b.js' })), // changed script => changed exec set
|
|
});
|
|
assert.strictEqual(res.status, 'aborted');
|
|
assert.strictEqual(res.requiresConsent, true);
|
|
assert.strictEqual(capManifestVersion(dir, 'e'), '1.0.0', 'old bundle untouched');
|
|
assert.strictEqual(readLedgerEntry(dir, 'e').version, '1.0.0', 'old ledger untouched');
|
|
assert.strictEqual(readSettings(dir).hooks.PostToolUse[0].hooks[0].command, 'hooks/a.js');
|
|
});
|
|
|
|
test('upgrade: a changed executable set WITH consent upgrades and re-derives shared edits', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/a.js' })),
|
|
});
|
|
const res = await lifecycle.upgradeCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '2.0.0', { script: 'hooks/b.js' })),
|
|
});
|
|
assert.strictEqual(res.status, 'upgraded');
|
|
const hooks = readSettings(dir).hooks.PostToolUse;
|
|
assert.strictEqual(hooks.length, 1);
|
|
assert.strictEqual(hooks[0].hooks[0].command, 'hooks/b.js', 'old shared edit stripped, new applied');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reconciliation (crash recovery) — proves "no half-state"
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function seedCapDir(dir, name, manifest) {
|
|
const d = path.join(dir, '.gsd', 'capabilities', name);
|
|
fs.mkdirSync(d, { recursive: true });
|
|
fs.writeFileSync(path.join(d, 'capability.json'), JSON.stringify(manifest), 'utf8');
|
|
return d;
|
|
}
|
|
// Record a ledger entry carrying an in-flight INTENT (the commit signal).
|
|
function recordPending(dir, id, version, { kind = 'upgrade', backupName, sharedFiles = [], sharedEdits = [] }) {
|
|
ledgerMod.recordInstall(dir, {
|
|
id, version, source: 's', integrity: '', files: ['.gsd/capabilities/' + id], sharedEdits,
|
|
_pending: { kind, backupName, sharedFiles },
|
|
});
|
|
}
|
|
|
|
test('reconcile: crash before new swapped in (final missing, backup present) rolls back to old', () => {
|
|
const dir = runtime();
|
|
seedCapDir(dir, 'c.upgrading-111-222', declarativeCap('c', '1.0.0')); // old, set aside
|
|
recordPending(dir, 'c', '1.0.0', { backupName: 'c.upgrading-111-222' });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledBack.includes('c'));
|
|
assert.strictEqual(capManifestVersion(dir, 'c'), '1.0.0');
|
|
assert.ok(!readLedgerEntry(dir, 'c')._pending, 'intent cleared');
|
|
assert.deepStrictEqual(fs.readdirSync(path.join(dir, '.gsd', 'capabilities')).filter((n) => n.includes('.upgrading-')), []);
|
|
});
|
|
|
|
test('reconcile: crash after swap before commit (intent present) rolls back to old', () => {
|
|
const dir = runtime();
|
|
seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new, uncommitted, live
|
|
seedCapDir(dir, 'c.upgrading-111-222', declarativeCap('c', '1.0.0')); // old backup
|
|
recordPending(dir, 'c', '1.0.0', { backupName: 'c.upgrading-111-222' });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledBack.includes('c'));
|
|
assert.strictEqual(capManifestVersion(dir, 'c'), '1.0.0', 'rolled back to old');
|
|
});
|
|
|
|
test('reconcile H3: SAME-version malicious bundle with intent present is rolled BACK, not mistaken for committed', () => {
|
|
const dir = runtime();
|
|
// Attacker ships different content under the SAME version string; crash before commit.
|
|
seedCapDir(dir, 'c', { id: 'c', version: '1.0.0', _evil: true }); // new (uncommitted), same version
|
|
seedCapDir(dir, 'c.upgrading-111-222', { id: 'c', version: '1.0.0', _evil: false }); // genuine old
|
|
recordPending(dir, 'c', '1.0.0', { backupName: 'c.upgrading-111-222' });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledBack.includes('c'), 'must roll back despite equal version strings');
|
|
const live = JSON.parse(fs.readFileSync(path.join(dir, '.gsd', 'capabilities', 'c', 'capability.json'), 'utf8'));
|
|
assert.strictEqual(live._evil, false, 'the genuine old bundle is restored, not the uncommitted one');
|
|
});
|
|
|
|
test('reconcile H2: rollback restores SHARED CONFIG (new hook is not stranded in settings.json)', () => {
|
|
const dir = runtime();
|
|
// Old bundle is declarative (no hooks). New bundle (uncommitted) added a hook that the
|
|
// mid-upgrade applied into settings.json before the crash.
|
|
seedCapDir(dir, 'c', execCap('c', '2.0.0', { script: 'hooks/new.js' })); // new, uncommitted, live
|
|
seedCapDir(dir, 'c.upgrading-111-222', declarativeCap('c', '1.0.0')); // old, declarative
|
|
// Simulate the new hook already written to settings.json (stamped).
|
|
fs.writeFileSync(
|
|
path.join(dir, 'settings.json'),
|
|
JSON.stringify({ hooks: { PostToolUse: [{ [CAP_MARKER]: 'c', hooks: [{ type: 'command', command: 'hooks/new.js' }] }] }, theme: 'dark' }),
|
|
'utf8',
|
|
);
|
|
recordPending(dir, 'c', '1.0.0', { backupName: 'c.upgrading-111-222', sharedFiles: ['settings.json'] });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledBack.includes('c'));
|
|
const after = readSettings(dir);
|
|
assert.ok(!after.hooks, 'the new hook was stripped (old bundle had none) — no stranded executable config');
|
|
assert.strictEqual(after.theme, 'dark', 'unrelated user config preserved');
|
|
});
|
|
|
|
test('reconcile: committed leftover backup (no intent) rolls forward, dropping the backup', () => {
|
|
const dir = runtime();
|
|
seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new, committed, live
|
|
seedCapDir(dir, 'c.upgrading-111-222', declarativeCap('c', '1.0.0')); // stale backup
|
|
// Committed: ledger entry has NO _pendingUpgrade.
|
|
ledgerMod.recordInstall(dir, { id: 'c', version: '2.0.0', source: 's', integrity: '', files: ['.gsd/capabilities/c'], sharedEdits: [] });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledForward.includes('c'));
|
|
assert.strictEqual(capManifestVersion(dir, 'c'), '2.0.0', 'new kept');
|
|
assert.deepStrictEqual(fs.readdirSync(path.join(dir, '.gsd', 'capabilities')).filter((n) => n.includes('.upgrading-')), []);
|
|
});
|
|
|
|
test('reconcile: an AGED staging orphan is swept, a FRESH one (possible in-flight resolve) is spared', () => {
|
|
const dir = runtime();
|
|
const stagingRoot = path.join(dir, '.gsd', 'capabilities', '.staging');
|
|
const aged = path.join(stagingRoot, 'aged-1');
|
|
const fresh = path.join(stagingRoot, 'fresh-2');
|
|
fs.mkdirSync(aged, { recursive: true });
|
|
fs.mkdirSync(fresh, { recursive: true });
|
|
// Backdate the aged orphan well past the in-flight grace window.
|
|
const old = new Date(Date.now() - 30 * 60 * 1000);
|
|
fs.utimesSync(aged, old, old);
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.orphansRemoved.includes('aged-1'), 'aged orphan swept');
|
|
assert.ok(!fs.existsSync(aged));
|
|
assert.ok(!report.orphansRemoved.includes('fresh-2'), 'fresh staging spared (could be live)');
|
|
assert.ok(fs.existsSync(fresh), 'fresh staging not deleted');
|
|
});
|
|
|
|
test('reconcile H1: a crashed FRESH install (intent kind=install) is rolled back — dir, edits, and entry removed', () => {
|
|
const dir = runtime();
|
|
// Simulate: install promoted the dir + wrote a hook into settings.json, then crashed before commit.
|
|
seedCapDir(dir, 'f', execCap('f', '1.0.0', { script: 'hooks/x.js' }));
|
|
fs.writeFileSync(
|
|
path.join(dir, 'settings.json'),
|
|
JSON.stringify({ hooks: { PostToolUse: [{ [CAP_MARKER]: 'f', hooks: [{ type: 'command', command: 'hooks/x.js' }] }] } }),
|
|
'utf8',
|
|
);
|
|
recordPending(dir, 'f', '1.0.0', { kind: 'install', backupName: null, sharedFiles: ['settings.json'] });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(report.rolledBack.includes('f'));
|
|
assert.strictEqual(readLedgerEntry(dir, 'f'), null, 'half-installed ledger entry removed');
|
|
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'f')), 'half-installed dir removed');
|
|
assert.ok(!readSettings(dir).hooks, 'stranded shared edit stripped');
|
|
});
|
|
|
|
test('reconcile M5: a tampered ledger key (non-kebab id) is skipped, never used in a delete path', () => {
|
|
const dir = runtime();
|
|
// A precious file under runtimeDir the traversal id would resolve to.
|
|
fs.writeFileSync(path.join(dir, 'precious.txt'), 'keep', 'utf8');
|
|
// Tamper: a ledger entry keyed by a traversal id with a fresh-install intent.
|
|
ledgerMod.recordInstall(dir, {
|
|
id: '../../precious', version: '1.0.0', source: 's', integrity: '',
|
|
files: ['.gsd/capabilities/x'], sharedEdits: [],
|
|
_pending: { kind: 'install', backupName: null, sharedFiles: [] },
|
|
});
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(!report.rolledBack.includes('../../precious'), 'tampered id not acted upon');
|
|
assert.ok(fs.existsSync(path.join(dir, 'precious.txt')), 'no delete via the tampered id');
|
|
});
|
|
|
|
test('reconcile M6: a wrong-id/malformed upgrade backupName fails CLOSED (intent left for retry)', () => {
|
|
const dir = runtime();
|
|
seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new, uncommitted, live
|
|
// Tampered intent: backupName names a DIFFERENT id.
|
|
recordPending(dir, 'c', '1.0.0', { kind: 'upgrade', backupName: 'other.upgrading-1-1', sharedFiles: [] });
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.ok(!report.rolledBack.includes('c'), 'must not silently accept');
|
|
assert.ok(readLedgerEntry(dir, 'c')._pending, 'intent left pending for manual handling');
|
|
});
|
|
|
|
test('reconcile H2: reinstall rollback restores OLD ledger metadata, not the new version', () => {
|
|
const dir = runtime();
|
|
seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new, uncommitted, live
|
|
seedCapDir(dir, 'c.upgrading-111-222', declarativeCap('c', '1.0.0')); // old backup
|
|
// Intent carries the OLD (prior) metadata + kind 'upgrade' (as installCapability now writes it).
|
|
recordPending(dir, 'c', '1.0.0', { kind: 'upgrade', backupName: 'c.upgrading-111-222', sharedFiles: [], sharedEdits: [] });
|
|
lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.strictEqual(capManifestVersion(dir, 'c'), '1.0.0', 'old files restored');
|
|
assert.strictEqual(readLedgerEntry(dir, 'c').version, '1.0.0', 'ledger metadata matches restored old bundle');
|
|
});
|
|
|
|
test('remove: a held lock makes remove report "in progress" (does not mutate)', () => {
|
|
const dir = runtime();
|
|
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
ledgerMod.recordInstall(dir, { id: 'q', version: '1.0.0', source: 's', integrity: '', files: ['.gsd/capabilities/q'], sharedEdits: [] });
|
|
fs.writeFileSync(lockPath, '99999', 'utf8');
|
|
try {
|
|
const res = lifecycle.removeCapability('q', { runtimeDir: dir });
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.ok(readLedgerEntry(dir, 'q'), 'entry not removed while locked');
|
|
} finally {
|
|
cleanup(lockPath);
|
|
}
|
|
});
|
|
|
|
test('reconcile: a held lock makes reconcile defer (no-op) and a mutation report "in progress"', async () => {
|
|
const dir = runtime();
|
|
// Manually hold the lock (fresh mtime => not stale).
|
|
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
fs.writeFileSync(lockPath, '99999', 'utf8');
|
|
try {
|
|
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
|
|
assert.deepStrictEqual(report.rolledBack, [], 'reconcile defers while another op holds the lock');
|
|
const res = await lifecycle.installCapability('./x', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('x')),
|
|
});
|
|
assert.strictEqual(res.status, 'blocked');
|
|
assert.ok(res.blockReasons.some((r) => /in progress/.test(r)));
|
|
} finally {
|
|
cleanup(lockPath);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Remove (surgical strip + user hand-edit fault case)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('remove: not-installed is idempotent', () => {
|
|
const dir = runtime();
|
|
assert.strictEqual(lifecycle.removeCapability('nope', { runtimeDir: dir }).status, 'not_installed');
|
|
});
|
|
|
|
test('remove: deletes recorded files, strips marked shared edits, drops the ledger entry', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0', { mcp: { 'cap-srv': { command: 'node' } } })),
|
|
});
|
|
const res = lifecycle.removeCapability('e', { runtimeDir: dir });
|
|
assert.strictEqual(res.status, 'removed');
|
|
assert.strictEqual(readLedgerEntry(dir, 'e'), null);
|
|
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'e')));
|
|
const settings = readSettings(dir);
|
|
assert.ok(!settings.hooks, 'empty hooks object pruned');
|
|
assert.ok(!settings.mcpServers, 'empty mcpServers object pruned');
|
|
});
|
|
|
|
test('remove: FAULT CASE — user hand-edited settings.json between install and remove is preserved', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0', { mcp: { 'cap-srv': { command: 'node' } } })),
|
|
});
|
|
// User hand-edits settings.json: adds their own (unmarked) hook + mcp server + a top-level field.
|
|
const sp = path.join(dir, 'settings.json');
|
|
const s = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
s.hooks.PostToolUse.push({ hooks: [{ type: 'command', command: 'user-script.js' }] });
|
|
s.mcpServers['user-srv'] = { command: 'user-mcp' };
|
|
s.theme = 'dark';
|
|
fs.writeFileSync(sp, JSON.stringify(s, null, 2), 'utf8');
|
|
|
|
const res = lifecycle.removeCapability('e', { runtimeDir: dir });
|
|
assert.strictEqual(res.status, 'removed');
|
|
const after = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
// Capability-owned entries gone; user's untouched.
|
|
assert.strictEqual(after.hooks.PostToolUse.length, 1);
|
|
assert.strictEqual(after.hooks.PostToolUse[0].hooks[0].command, 'user-script.js');
|
|
assert.deepStrictEqual(after.mcpServers, { 'user-srv': { command: 'user-mcp' } });
|
|
assert.strictEqual(after.theme, 'dark');
|
|
});
|
|
|
|
test('remove: tolerates a user having already deleted the shared file and the install dir', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
|
|
_resolve: fakeResolve(execCap('e', '1.0.0')),
|
|
});
|
|
cleanup(path.join(dir, 'settings.json'));
|
|
cleanup(path.join(dir, '.gsd', 'capabilities', 'e'));
|
|
const res = lifecycle.removeCapability('e', { runtimeDir: dir });
|
|
assert.strictEqual(res.status, 'removed', 'idempotent despite missing artifacts');
|
|
assert.strictEqual(readLedgerEntry(dir, 'e'), null);
|
|
});
|
|
|
|
test('remove: a tampered ledger files[] routed through a symlink cannot delete outside runtimeDir', () => {
|
|
const dir = runtime();
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-outside-'));
|
|
cleanups.push(outside);
|
|
const victim = path.join(outside, 'victim.txt');
|
|
fs.writeFileSync(victim, 'precious', 'utf8');
|
|
// A symlink inside runtimeDir pointing OUT, plus a tampered ledger routing files[] through it.
|
|
fs.mkdirSync(path.join(dir, '.gsd'), { recursive: true });
|
|
fs.symlinkSync(outside, path.join(dir, '.gsd', 'link'), 'dir');
|
|
ledgerMod.recordInstall(dir, {
|
|
id: 'evil', version: '1.0.0', source: 's', integrity: '',
|
|
files: ['.gsd/link/victim.txt'], sharedEdits: [],
|
|
});
|
|
lifecycle.removeCapability('evil', { runtimeDir: dir });
|
|
assert.ok(fs.existsSync(victim), 'a file OUTSIDE runtimeDir (reached via symlink) must NOT be deleted');
|
|
});
|
|
|
|
test('remove: CAPABILITY_DATA is preserved by default and deleted only on removeData', async () => {
|
|
const dir = runtime();
|
|
await lifecycle.installCapability('./e', {
|
|
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
|
|
_resolve: fakeResolve(declarativeCap('e')),
|
|
});
|
|
const dataDir = path.join(dir, '.gsd', 'capability-data', 'e');
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
fs.writeFileSync(path.join(dataDir, 'state.json'), '{}', 'utf8');
|
|
|
|
const keep = lifecycle.removeCapability('e', { runtimeDir: dir });
|
|
assert.strictEqual(keep.dataPreserved, true);
|
|
assert.ok(fs.existsSync(dataDir), 'data preserved by default');
|
|
|
|
// Re-install then remove with removeData.
|
|
await lifecycle.installCapability('./e', { runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('e')) });
|
|
const wipe = lifecycle.removeCapability('e', { runtimeDir: dir, removeData: true });
|
|
assert.strictEqual(wipe.dataPreserved, false);
|
|
assert.ok(!fs.existsSync(dataDir), 'data deleted on removeData');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared-edit helpers (direct) — prototype-pollution guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('applyCapabilitySharedEdits: __proto__ event/name is skipped (no pollution)', () => {
|
|
const dir = runtime();
|
|
lifecycle.applyCapabilitySharedEdits({
|
|
runtimeDir: dir,
|
|
capId: 'p',
|
|
manifest: { hooks: [{ event: '__proto__', script: 'x.js' }, { event: 'Real', script: 'y.js' }], mcpServers: { __proto__: { command: 'evil' }, ok: { command: 'node' } } },
|
|
sharedFiles: ['settings.json'],
|
|
});
|
|
const s = readSettings(dir);
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(s.hooks, '__proto__'));
|
|
assert.ok(Array.isArray(s.hooks.Real));
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(s.mcpServers, '__proto__'));
|
|
assert.ok(s.mcpServers.ok);
|
|
// The global prototype was not polluted.
|
|
assert.strictEqual({}.command, undefined);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Real-resolver integration (no _resolve seam)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('integration: install a real, valid, declarative local capability through the real resolver', async () => {
|
|
const dir = runtime();
|
|
// Build a valid local capability source dir.
|
|
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-src-'));
|
|
cleanups.push(src);
|
|
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(declarativeCap('realcap')), 'utf8');
|
|
|
|
const res = await lifecycle.installCapability(src, { runtimeDir: dir, hostVersion: '1.6.0' });
|
|
assert.strictEqual(res.status, 'installed', JSON.stringify(res));
|
|
assert.strictEqual(res.id, 'realcap');
|
|
assert.ok(fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'realcap', 'capability.json')));
|
|
assert.ok(readLedgerEntry(dir, 'realcap'), 'ledger entry recorded via real path');
|
|
});
|