fix(#1459): user-owned consent store gates third-party capability activation; env/cwd in disclosure; loader validator parity (#1473)

This commit is contained in:
Tom Boucher
2026-06-20 01:59:03 -04:00
committed by GitHub
parent 81d4b6d573
commit e7855bc217
30 changed files with 5125 additions and 685 deletions

View File

@@ -2093,40 +2093,45 @@ test('finding-4: releaseLock STILL deletes our own unchanged lock (inode guard i
test('CONC-2: acquireLock returns null on contention exhaustion WITHOUT a stack overflow (bounded loop)', (t) => {
const dir = runtime();
const { mock } = require('node:test');
fs.mkdirSync(path.join(dir, '.gsd', 'capabilities'), { recursive: true });
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Force every open to look "held" (EEXIST) and every stat to look STALE with a DEAD pid,
// so the steal path is always taken — but the rename never actually frees the lock (we make
// the lockfile re-appear). This exercises the retry loop to exhaustion.
// TV-06/07/11/17: a FAITHFUL pathological-contention sim. Write a REAL, stale, DEAD-pid JSON lock body
// on disk so the lock body is read through the actual fd-based bounded reader (openSync 'r' → fstatSync
// → readSync) — exactly the production path — instead of a bogus readFileSync mock that never fires.
// The DEAD pid (probe alive:false) makes the holder steal-eligible.
fs.writeFileSync(lockPath, lockBody({ pid: 999999999, host: os.hostname(), startTime: null, ts: Date.now() - 10 * 60 * 1000 }), 'utf8');
withLockProbes(t, { alive: false, startTime: null }); // dead, unverifiable → steal-eligible
// openSync: only the EXCLUSIVE create ('wx') of the .lock is forced to EEXIST (always "held"); every
// other open — including the fd reader's O_RDONLY open of the lock body — delegates to the real fn so
// the JSON body is genuinely read. TV-06: capture the real fn BEFORE mocking and delegate in else.
const realOpen = fs.openSync.bind(fs);
const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') {
const err = new Error('EEXIST: file already exists');
err.code = 'EEXIST';
throw err;
const err = new Error('EEXIST: file already exists'); err.code = 'EEXIST'; throw err;
}
return realOpen(p, flags, ...rest);
});
// statSync: present + stale (old mtime) so the steal branch is taken every time.
// statSync: keep the .lock looking PRESENT + STALE so the steal branch is taken on every attempt even
// after a real rename moves the file aside. TV-06: capture+delegate the real fn in the else branch.
// TV-11: include `size` (and dev/ino) so a stat consumer that reads them gets a complete stat object.
const realStat = fs.statSync.bind(fs);
const staleMtime = Date.now() - 10 * 60 * 1000;
const statMock = mock.method(fs, 'statSync', function (p, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock')) {
return { mtimeMs: Date.now() - 10 * 60 * 1000, isFile: () => true };
return { mtimeMs: staleMtime, size: 256, dev: 1, ino: 1, isFile: () => true, isDirectory: () => false };
}
return require('node:fs').statSync.wrappedMethod
? require('node:fs').statSync.wrappedMethod(p, ...rest)
: p;
return realStat(p, ...rest);
});
// The lockfile reads as a dead-pid token so the steal is "allowed" but never succeeds in
// freeing the path (open keeps throwing EEXIST).
const realReadFile = fs.readFileSync.bind(fs);
const readMock = mock.method(fs, 'readFileSync', function (p, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock')) return '999999999-1-1';
return realReadFile(p, ...rest);
});
// rename "succeeds" (so we proceed to retry) but the next open still throws EEXIST.
const renameMock = mock.method(fs, 'renameSync', function () { /* no-op: lock stays held */ });
const rmMock = mock.method(fs, 'rmSync', function () { /* no-op */ });
t.after(() => { openMock.mock.restore(); statMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); rmMock.mock.restore(); });
// renameSync / rmSync: TV-18 — delegate to the REAL fns (capture before mocking). A real steal moves
// the lock aside and removes it, but the mocked 'wx' open keeps throwing EEXIST, so acquireLock can
// never actually acquire → the bounded loop runs to exhaustion and returns null (no recursion/SO).
const realRename = fs.renameSync.bind(fs);
const realRm = fs.rmSync.bind(fs);
const renameMock = mock.method(fs, 'renameSync', function (src, dst, ...rest) { return realRename(src, dst, ...rest); });
const rmMock = mock.method(fs, 'rmSync', function (p, ...rest) { return realRm(p, ...rest); });
t.after(() => { openMock.mock.restore(); statMock.mock.restore(); renameMock.mock.restore(); rmMock.mock.restore(); });
let handle;
assert.doesNotThrow(
@@ -2458,11 +2463,14 @@ test('DOS-2: reconcile with N pending entries writes the ledger at most once for
return realWrite(rd, ledger);
});
// removeEntry also writes the ledger internally; spy it too so any per-entry path is visible.
// TV-10: the batched step-1 path must NEVER call removeEntry (it mutates the in-memory ledger and
// writes once). The spy is a pure COUNTER — it intentionally does NOT delegate to the real
// removeEntry: a call here would be the bug under test (a per-entry write), so we only record that it
// happened (the count assertion below fails) rather than masking it behind a misleading call-through.
let removeEntryCalls = 0;
const realRemove = ledgerMod.removeEntry.bind(ledgerMod);
const removeMock = mock.method(ledgerMod, 'removeEntry', function (rd, id) {
const removeMock = mock.method(ledgerMod, 'removeEntry', function () {
removeEntryCalls++;
return realRemove(rd, id);
return false; // not delegated on purpose — see TV-10 note above.
});
t.after(() => { writeMock.mock.restore(); removeMock.mock.restore(); });
@@ -2554,3 +2562,366 @@ test('W-3/DUR-5: reconcile sweeps a stale .gsd-capabilities.json.tmp.* orphan fr
assert.ok(!fs.existsSync(staleTmp), 'stale orphan tmp file must be swept by reconcile (W-3/DUR-5)');
assert.ok(fs.existsSync(freshTmp), 'a fresh tmp file (possible in-flight write) must NOT be swept');
});
// ---------------------------------------------------------------------------
// Consent store binding on project install / upgrade / remove (#1459)
// ---------------------------------------------------------------------------
const consentMod = require('../gsd-core/bin/lib/capability-consent.cjs');
const trustMod = require('../gsd-core/bin/lib/capability-trust.cjs');
/** A consent home OUTSIDE the project tree (user-owned). */
function consentHome() {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cap-consent-home-')));
cleanups.push(dir);
return dir;
}
/**
* #1459 CB-1/CB-2: the SECURITY binding `hasProjectConsent` checks is the RECOMPUTED full-bundle
* content hash over the INSTALLED capDir (`<runtimeDir>/.gsd/capabilities/<id>`) — exactly what the
* loader recomputes at load. Tests assert consent presence by recomputing the same hash here.
*/
function installedCapDir(runtimeDir, id) {
return path.join(runtimeDir, '.gsd', 'capabilities', id);
}
function installedContentHash(runtimeDir, id) {
return consentMod.bundleContentHash(installedCapDir(runtimeDir, id));
}
test('install (project scope, consented): writes a consent record under the consent home, NOT the project', async () => {
const dir = fs.realpathSync(runtime()); // project runtimeDir
const home = consentHome();
const cap = declarativeCap('proj-decl');
const res = await lifecycle.installCapability('./proj', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(cap, { integrity: 'sha512-proj' }),
});
assert.strictEqual(res.status, 'installed');
// The consent record matches what the loader will check — the RECOMPUTED full-bundle content hash
// over the installed capDir (#1459 CB-1/CB-2). A declarative-only cap has NO executable surface, so
// before content binding it had a constant disclosure signature and a repo-write could swap its
// manifest while consent still matched; the contentHash binds the whole bundle.
assert.strictEqual(
consentMod.hasProjectConsent({
gsdHome: home, projectRoot: dir, id: 'proj-decl',
contentHash: installedContentHash(dir, 'proj-decl'),
}),
true,
'a matching consent record was written under the consent home',
);
// It is under the consent HOME, not under the project runtimeDir.
assert.ok(fs.existsSync(consentMod.consentStorePath(home)), 'store under consent home');
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'consent.json')), 'NOT written inside the project');
});
test('install (project scope, executable + consented): consent record matches the executable disclosure signature', async () => {
const dir = fs.realpathSync(runtime());
const home = consentHome();
const cap = execCap('proj-exec', '1.0.0', { mcp: { srv: { command: 'node', env: { NODE_OPTIONS: '--inspect' } } } });
const res = await lifecycle.installCapability('./pe', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
consentGranted: true, sharedFiles: ['settings.json'],
_resolve: fakeResolve(cap, { integrity: 'sha512-pe' }),
});
assert.strictEqual(res.status, 'installed');
// The recorded contentHash must equal the recomputed full-bundle hash of the installed capDir so
// the loader re-activates it; a tampered manifest/script later changes the recomputed hash and
// deactivates (loader test). The stored disclosureSignature (incl. env) remains for the UX layer.
assert.strictEqual(
consentMod.hasProjectConsent({
gsdHome: home, projectRoot: dir, id: 'proj-exec',
contentHash: installedContentHash(dir, 'proj-exec'),
}),
true,
);
// The record ALSO retains the executable disclosure signature for the re-consent-on-change UX.
const store = consentMod.readConsentStore(home);
const rec = store.records[`${dir}\0proj-exec`];
assert.ok(rec, 'consent record present');
assert.strictEqual(rec.disclosureSignature, trustMod.signatureForManifest(cap), 'disclosure signature retained on the record');
});
test('install (GLOBAL scope): writes NO consent record (global is trusted as today)', async () => {
const dir = fs.realpathSync(runtime());
const home = consentHome();
const res = await lifecycle.installCapability('./g', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'global', consentStoreDir: home,
_resolve: fakeResolve(declarativeCap('global-decl'), { integrity: 'sha512-g' }),
});
assert.strictEqual(res.status, 'installed');
// No store file (or an empty one) — global scope never records consent.
const store = consentMod.readConsentStore(home);
assert.deepStrictEqual(Object.keys(store.records), [], 'global install records no consent');
});
test('remove (project scope): revokes the consent record', async () => {
const dir = fs.realpathSync(runtime());
const home = consentHome();
const cap = declarativeCap('proj-rm');
await lifecycle.installCapability('./rm', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(cap, { integrity: 'sha512-rm' }),
});
const rmHash = installedContentHash(dir, 'proj-rm');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-rm', contentHash: rmHash }),
true,
'consent present after install',
);
const rm = lifecycle.removeCapability('proj-rm', { runtimeDir: dir, scope: 'project', consentStoreDir: home });
assert.strictEqual(rm.status, 'removed');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-rm', contentHash: rmHash }),
false,
'remove fully revokes the consent record',
);
});
// Finding 3 (MED, #1459 round 6): removeProjectConsent now THROWS on a consent-lock failure
// (round-3). removeCapability must NOT silently swallow that throw and still report a clean
// 'removed' — that leaves a STALE consent record a byte-identical re-drop + forged ledger could
// reactivate against. The revoke failure must be SURFACED (a stderr warning naming the record AND
// a flag in the returned result) so the user knows to clear it (`gsd capability trust revoke`).
function plantFreshConsentLockLife(home) {
const lockPath = consentMod.consentLockPath(home);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// A fresh JSON lock body (matching the shared lock primitive shape) so acquireConsentLock cannot
// steal it within its attempt budget → revokeProjectConsent throws.
const body = JSON.stringify({ token: `${process.pid}-${Date.now()}-1`, pid: process.pid, hostname: os.hostname(), startTime: 'CSTART', ts: Date.now() });
fs.writeFileSync(lockPath, body, 'utf8');
return lockPath;
}
test('remove (project scope): a revoke-on-lock-failure is SURFACED, not swallowed (no silent clean removed with a stale consent record)', async (t) => {
// revert-fails: the old remove path wrapped revokeProjectConsent in `try { … } catch { /* best-effort */ }`
// and returned `{ status: 'removed' }` regardless — so with the consent lock held, revoke throws, the
// catch swallows it, and the result is a clean 'removed' with NO indication the consent record is stale.
// This asserts the result carries consentRevokeFailed:true (and a warning) — which is FALSE/absent under
// the swallow-and-return-clean implementation and only true once the failure is surfaced.
const dir = fs.realpathSync(runtime());
const home = consentHome();
const cap = declarativeCap('proj-rm-lk');
await lifecycle.installCapability('./rmlk', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(cap, { integrity: 'sha512-rmlk' }),
});
const rmHash = installedContentHash(dir, 'proj-rm-lk');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-rm-lk', contentHash: rmHash }),
true, 'consent present after install',
);
// Hold the consent-store lock so the revoke inside remove cannot acquire it → revokeProjectConsent throws.
const lockPath = plantFreshConsentLockLife(home);
t.after(() => { try { fs.unlinkSync(lockPath); } catch { /* best-effort */ } });
const rm = lifecycle.removeCapability('proj-rm-lk', { runtimeDir: dir, scope: 'project', consentStoreDir: home });
// The files/ledger are gone (removal succeeded) — but the consent revoke FAILED and must be surfaced.
assert.strictEqual(rm.status, 'removed', 'the files+ledger removal still succeeds');
assert.strictEqual(readLedgerEntry(dir, 'proj-rm-lk'), null, 'ledger entry removed');
assert.strictEqual(rm.consentRevokeFailed, true,
'the result must flag consentRevokeFailed:true so the CLI can report a non-clean removal (a swallowed throw would leave this undefined)');
assert.ok(
typeof rm.consentRevokeWarning === 'string' && /consent|revoke|trust revoke/i.test(rm.consentRevokeWarning),
`the result must carry a warning naming the stale consent record; got: ${JSON.stringify(rm.consentRevokeWarning)}`,
);
// The consent record is STALE (could not be revoked) — confirming the failure was real, not a no-op.
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-rm-lk', contentHash: rmHash }),
true, 'the consent record is left STALE (revoke was blocked by the held lock) — the user must clear it',
);
});
test('upgrade (project scope, consented): re-records the consent for the new version', async () => {
const dir = fs.realpathSync(runtime());
const home = consentHome();
const capV1 = execCap('proj-up', '1.0.0', { script: 'hooks/a.js' });
await lifecycle.installCapability('./up', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
consentGranted: true, sharedFiles: ['settings.json'],
_resolve: fakeResolve(capV1, { integrity: 'sha512-up1' }),
});
// Capture the V1 bundle content hash BEFORE the upgrade overwrites the on-disk bundle, so TV-05 can
// prove the OLD-version binding no longer matches after the re-record.
const v1Hash = installedContentHash(dir, 'proj-up');
// Upgrade to v2 with the SAME executable set (no re-consent prompt) — consent re-recorded for v2.
const capV2 = execCap('proj-up', '2.0.0', { script: 'hooks/a.js' });
const up = await lifecycle.upgradeCapability('./up', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
consentGranted: true, sharedFiles: ['settings.json'],
_resolve: fakeResolve(capV2, { integrity: 'sha512-up2' }),
});
assert.strictEqual(up.status, 'upgraded');
const v2Hash = installedContentHash(dir, 'proj-up');
assert.notStrictEqual(v1Hash, v2Hash, 'precondition: the v1 and v2 bundles hash differently');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-up', contentHash: v2Hash }),
true,
'consent re-recorded against the upgraded bundle content hash',
);
// TV-05: the upgrade must REPLACE the consent record in place — no second STALE record bound to the
// OLD version may linger. revert-fails: if the upgrade ADDED a new record instead of overwriting (or
// left the v1 binding around), the store would carry 2 records and/or the OLD hash would still match.
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'proj-up', contentHash: v1Hash }),
false,
'the OLD-version content hash no longer matches (no stale consent record)',
);
const store = consentMod.readConsentStore(home);
assert.strictEqual(Object.keys(store.records).length, 1, 'exactly one consent record for the (project, id) — no duplicate');
});
// ---------------------------------------------------------------------------
// D — IC-01/CB-4: install from a SUBDIR records consent at the project root, so the loader (which
// looks up via consentProjectRoot = realpath(findProjectRoot(cwd))) finds it from any descendant.
// ---------------------------------------------------------------------------
const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs');
test('D (IC-01/CB-4): install --scope project from a SUBDIR → cap is ACTIVE (record key matches loader lookup)', async () => {
// revert-fails: if the RECORD site bound consent to realpath(subdir) and the loader looked it up at
// realpath(findProjectRoot(cwd)), the keys would differ and the freshly installed cap would be
// immediately INACTIVE (install-then-inactive). The CLI resolves cwd→project root via
// findProjectRoot BEFORE install (capability is not in SKIP_ROOT_RESOLUTION), so the record lands at
// the project root; the loader's consentProjectRoot resolves the same root from a deep subdir. This
// test simulates that: install at the project root, then load from a nested subdir.
const projectRoot = fs.realpathSync(runtime());
fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); // project-root marker for findProjectRoot
const subdir = path.join(projectRoot, 'a', 'b', 'c');
fs.mkdirSync(subdir, { recursive: true });
const home = consentHome();
const cap = declarativeCap('subdir-cap');
cap.skills = ['subdir-skill'];
const res = await lifecycle.installCapability('./sd', {
// The CLI passes the findProjectRoot-resolved cwd as runtimeDir; here that is the project root.
runtimeDir: projectRoot, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(cap, { integrity: '' }), // local install → empty integrity (CB-3 path)
});
assert.strictEqual(res.status, 'installed');
// Load the registry FROM the nested subdir, pointing the consent home at the same store. The loader
// resolves the project root (findProjectRoot finds the .planning/ marker) and finds the record.
const reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: subdir, hostVersion: '1.6.0' });
assert.ok(reg.capabilities && reg.capabilities['subdir-cap'], 'cap ACTIVE when loaded from a subdir (no install-then-inactive)');
assert.strictEqual(reg.bySkill['subdir-skill'], 'subdir-cap', 'skill surface present from the subdir');
});
// ---------------------------------------------------------------------------
// IC-03: a reconcile rollback that DELETES a project-scope entry whose bundle dir is gone must also
// REVOKE the now-stale consent, so a later re-dropped BYTE-IDENTICAL bundle of the same id stays
// INACTIVE (it cannot silently re-activate against the stale record whose content hash still matches).
// ---------------------------------------------------------------------------
test('IC-03: reconcile rollback of a deleted project bundle revokes consent → identical re-drop stays INACTIVE', async () => {
// revert-fails: drop the revokeStaleConsent(id) call in reconcile's install-rollback branch → the
// stale consent record survives the rollback, so the byte-identical re-drop (same content hash) would
// RE-ACTIVATE against it and the final inactive assertion would FAIL.
const dir = fs.realpathSync(runtime());
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); // genuine project marker (CB-3 safe)
const home = consentHome();
const cap = declarativeCap('redrop-cap');
cap.skills = ['redrop-skill'];
// 1. Real project install — records a user consent record bound to the installed bundle content hash.
const installed = await lifecycle.installCapability('./rd', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(cap, { integrity: '' }),
});
assert.strictEqual(installed.status, 'installed');
const consentedHash = installedContentHash(dir, 'redrop-cap');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'redrop-cap', contentHash: consentedHash }),
true, 'consent present after install',
);
// 2. Simulate a crashed/interrupted state: mark the entry as an in-flight (uncommitted) install and
// delete its on-disk bundle dir. reconcile's install-rollback path then drops the entry.
recordPending(dir, 'redrop-cap', '1.0.0', { kind: 'install', backupName: null, sharedFiles: [] });
cleanup(path.join(dir, '.gsd', 'capabilities', 'redrop-cap')); // delete the on-disk bundle dir (helpers.cleanup: Windows-EBUSY retry budget)
// 3. Reconcile WITH the consent context — the rollback must revoke the stale consent.
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir, scope: 'project', consentStoreDir: home });
assert.ok(report.rolledBack.includes('redrop-cap'), 'the deleted-bundle entry is rolled back');
assert.strictEqual(
consentMod.hasProjectConsent({ gsdHome: home, projectRoot: dir, id: 'redrop-cap', contentHash: consentedHash }),
false, 'reconcile rollback revoked the now-stale consent record',
);
// 4. Re-drop the BYTE-IDENTICAL bundle + a committed (forged) project ledger — no new consent.
const reDir = path.join(dir, '.gsd', 'capabilities', 'redrop-cap');
fs.mkdirSync(reDir, { recursive: true });
fs.writeFileSync(path.join(reDir, 'capability.json'), JSON.stringify(cap), 'utf8');
assert.strictEqual(consentMod.bundleContentHash(reDir), consentedHash, 'precondition: the re-drop is byte-identical (same hash)');
fs.writeFileSync(path.join(dir, '.gsd-capabilities.json'), JSON.stringify({
version: '1', updatedAt: '2026-01-01T00:00:00Z',
entries: { 'redrop-cap': { id: 'redrop-cap', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] } },
}), 'utf8');
// 5. The loader must NOT re-activate the identical re-drop — consent was revoked.
const reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: dir, hostVersion: '1.6.0' });
assert.ok(reg.capabilities['redrop-cap'] === undefined, 'an identical re-drop stays INACTIVE after the rollback revoked consent');
});
// ---------------------------------------------------------------------------
// IC-07: a PROJECT-scope install/upgrade with NO consentStoreDir cannot bind consent. That used to be
// a SILENT skip (cap inactive with no explanation). It must now emit an observable stderr warning.
// ---------------------------------------------------------------------------
test('IC-07: project-scope install WITHOUT a consentStoreDir warns on stderr (consent binding skipped)', async () => {
// revert-fails: remove warnIfConsentSkipped's emit → the install still succeeds but NO warning is
// written, so the /consentStoreDir|consent binding was SKIPPED/i match below fails.
const dir = fs.realpathSync(runtime());
const orig = process.stderr.write.bind(process.stderr);
let buf = '';
process.stderr.write = (chunk, ...rest) => { buf += String(chunk); return orig(chunk, ...rest); };
let res;
try {
res = await lifecycle.installCapability('./nostore', {
// scope:'project' but NO consentStoreDir → bind cannot run.
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project',
_resolve: fakeResolve(declarativeCap('no-store-cap'), { integrity: '' }),
});
} finally {
process.stderr.write = orig;
}
assert.strictEqual(res.status, 'installed', 'the install still succeeds (binding skip is non-fatal)');
assert.match(buf, /capability consent:/i, 'a consent diagnostic was written to stderr');
assert.match(buf, /no-store-cap/, 'the warning names the capability');
assert.match(buf, /skip/i, 'the warning states consent binding was skipped');
});
// ---------------------------------------------------------------------------
// IC-05 / WIN-2: a consent-store write failure (read-only/UNC/NFS) must NOT fail an otherwise-
// successful install — surface a non-fatal warning naming the store path and let the install succeed.
// ---------------------------------------------------------------------------
test('IC-05/WIN-2: a consent-store write failure leaves the install status:installed + warns', async () => {
// revert-fails: if bindProjectConsent re-threw (or the warning were dropped), the install would
// either throw / return non-installed OR succeed silently — both fail an assertion below.
const dir = fs.realpathSync(runtime());
const home = consentHome();
// Simulate an unwritable store: mock recordProjectConsent to throw (read-only/UNC/NFS surrogate).
const realRecord = consentMod.recordProjectConsent.bind(consentMod);
const recMock = mock.method(consentMod, 'recordProjectConsent', function () {
const err = new Error('EROFS: read-only file system, open consent.json'); err.code = 'EROFS'; throw err;
});
const orig = process.stderr.write.bind(process.stderr);
let buf = '';
process.stderr.write = (chunk, ...rest) => { buf += String(chunk); return orig(chunk, ...rest); };
let res;
try {
res = await lifecycle.installCapability('./rofs', {
runtimeDir: dir, hostVersion: '1.6.0', scope: 'project', consentStoreDir: home,
_resolve: fakeResolve(declarativeCap('rofs-cap'), { integrity: '' }),
});
} finally {
process.stderr.write = orig;
recMock.mock.restore();
}
void realRecord;
assert.strictEqual(res.status, 'installed', 'a consent-store IO error must NOT fail an otherwise-successful install');
assert.ok(fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'rofs-cap', 'capability.json')), 'the bundle is committed on disk');
assert.match(buf, /capability consent:/i, 'a consent diagnostic was written to stderr');
assert.match(buf, /could not write the consent record/i, 'the warning explains the write failure');
assert.match(buf, new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'the warning names the consent store path');
});