Files
msd-core/tests/capability-lifecycle.test.cjs
Tom Boucher dc3c81e93d chore(#3212): src/pattern.cts is the sole owner of runtime-value regex construction — Phase 1 (#3416)
* test(#3412): failing-first suite for the pattern-construction seam

Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.

Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.

* chore(#3412): src/pattern.cts owns runtime-value regex construction

Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.

The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.

Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.

RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.

Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.

Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.

* fix(#3412): close the _SOURCE guard evasion, correct two false claims

Three findings from the orthogonal review pass, all fixed.

1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
   name matching with no binding check, so `new RegExp(userInput_SOURCE)`
   — a function parameter — sailed past the guard. That is the same
   rename-evasion class issue #3410 documents, reopened by the very
   fallback meant to complement the structural check. Now bound to the
   identifier's actual binding kind: import, require-derived const, or
   module-scope const; parameters, `let`/`var`, and unresolvable
   bindings fail closed. Four RuleTester cases cover the evasion and
   prove the legitimate cross-module case still passes.

2. src/pattern.cts's own header carried the stale pre-correction counts
   (12 copies / 17 call sites) while CONTEXT.md and the design doc
   carried the corrected ones (~39 / ~44) — a self-contradiction inside
   the PR whose entire purpose is deleting divergent copies. Rewritten,
   preserving the durable lesson: a named-function census cannot see
   inline copies; only a shape-matching guard can.

3. The claim that all deleted copies threw TypeError on non-string was
   false. phase-id.cts's copy — the one with 8 external importers — did
   String(value).replace(...) and never threw. The seam's locked
   signature does not coerce, so this is a real, now-disclosed behavior
   change rather than the pure preservation the tests asserted. Audited
   all 32 invocations across the 8 importers and 6 in-file callers:
   every one is safe by construction (upstream truthy guard or a
   string-producing derivation), verified by runtime probe against the
   compiled modules rather than by TS compilation, which cannot see a
   runtime undefined. Corrected the false claim in both the test comment
   and the design doc, and added it to Known limits.

* docs(#3412): add Changed changeset for the Node 24 floor

The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.

* fix(#3412): resolve the seam's require graph in script fixtures and packaging

Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:

1. ~82 failures — tests/adr-index-gate.test.cjs and
   tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
   mkdtemp fixture and spawn it there (necessary: those scripts resolve
   their scan root from __dirname/.., so running the real script would
   scan the real repo). Each harness hand-listed the dependencies to
   copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
   gen-adr-index.cjs made both lists silently incomplete ->
   MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
   failures from the same crash.

   Fixed as a class, not an instance: new tests/helpers/copy-script-
   fixture.cjs walks a script's transitive static relative-require graph
   and copies it, so dependencies are derived and never re-declared. It
   throws (naming the unbuilt artifact) instead of letting the child die
   with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
   scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
   contract, sync-runtime-launcher.

2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
   the new scripts/lint-no-adhoc-regex-escape.cjs would be
   MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
   the tarball, matching the existing precedent for gen-emitted-
   baseline.cjs, which is excluded for the identical reason, and locked
   with a test modeled on that one. Confirmed against a real npm pack:
   890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
   present (so the other four scripts' requires are legitimate).

3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
   source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
   the retired hand-rolled escaper but NOT text-equivalent: it hex-
   escapes the leading character and all hyphens ('0*\x329',
   '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
   decisions across all three real interpolation prefixes, zero
   divergence. Those tests now compile each source into the same heading
   regex src/roadmap.cts's searchPhaseInContent builds and assert what
   matches and what does not, including the 'i'-flag canonicalization
   the hex escape has to preserve. Re-pinning the new literals would
   have rebuilt the same brittleness one layer down. Adds a test for the
   property the escape exists for: a dot in '1.2' must not act as a
   wildcard.

Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): refuse to copy a fixture dependency outside the fixture root

copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).

No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract

Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.

HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.

This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.

espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.

MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.

LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.

MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.

MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.

MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3412): backfill changeset pr number to 3416

* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision

Two CI failures on PR #3416, both in code this branch added.

CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.

Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.

Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.

Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.

Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.

Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:19:57 -04:00

3400 lines
185 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 { mock } = 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 { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const { CAP_MARKER } = lifecycle;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const { runHook } = require('./helpers/process-seam.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
/** POSIX-only: make a FIFO at `p` (returns false where mkfifo is unavailable). */
function tryMkfifoLife(p) {
if (process.platform === 'win32') return false;
const res = runHook(p, [], { interpreter: 'mkfifo', timeoutMs: PROBE_TIMEOUT_MS });
return res.exitCode === 0;
}
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; }
}
/**
* #1460 CONF-1: the expected absolute hook command — `script` resolved against the
* capability install dir and confined via realpath of the existing ancestor chain
* (so an ancestor symlink cannot escape). Mirrors confinedBundleScript in the source.
*/
function expectedBundleCommand(dir, id, script) {
const capDir = path.join(dir, '.gsd', 'capabilities', id);
const target = path.resolve(capDir, script);
let parent = path.dirname(target);
try { parent = fs.realpathSync(parent); } catch { /* lexical fallback below */ }
return path.join(parent, path.basename(target));
}
// ---------------------------------------------------------------------------
// 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 deadman-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 });
// A legacy no-pid body: liveness cannot be verified, so (finding 1) it is reclaimable ONLY by the
// HARD deadman timeout — backdate it past LOCK_DEADMAN_MS (10 min) to simulate a crashed holder the
// deadman must eventually free. (An under-deadman no-pid lock is intentionally NOT stolen; that case
// is covered in the finding-1 lock suite.)
fs.writeFileSync(lockPath, 'dead-holder-token', 'utf8');
const old = new Date(Date.now() - 11 * 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', 'deadman-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');
// #1460 CONF-1/(R): command is the ABSOLUTE confined path inside the bundle (POSIX single-quoted), not the raw relative form.
assert.strictEqual(readSettings(dir).hooks.PostToolUse[0].hooks[0].command, nodeQuoted(expectedBundleCommand(dir, 'e', '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);
// #1460 CONF-1/(R): re-derived command is the ABSOLUTE confined path inside the bundle (POSIX single-quoted).
assert.strictEqual(hooks[0].hooks[0].command, nodeQuoted(expectedBundleCommand(dir, 'e', 'hooks/b.js')), 'old shared edit stripped, new applied (node + quoted absolute confined path)');
});
// ---------------------------------------------------------------------------
// #1460 (R) HIGH: the emitted hook `command` must be shell-safe
// ---------------------------------------------------------------------------
// A hook `command` string is consumed by a shell (first-party hooks emit
// `node "${CLAUDE_PLUGIN_ROOT}/hooks/x.js"`). The non-manifest install-prefix
// (the home/runtime dir) commonly contains spaces (e.g. "/Users/Bob Smith/...")
// — written unquoted it word-splits and breaks (or, with a hostile prefix,
// could inject). The emitted absolute command must be POSIX single-quoted.
/** A runtime dir whose absolute path contains a SPACE (mirrors "/Users/Bob Smith/.claude"). */
function runtimeWithSpace() {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cap life-'));
cleanups.push(base);
const dir = path.join(base, 'Bob Smith', '.claude');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
/** POSIX single-quote a string the way applyCapabilitySharedEdits must. */
function shQuote(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
/** #1634: a `.js`-family hook command is emitted as `node ` + the POSIX-quoted absolute path. */
function nodeQuoted(p) {
return 'node ' + shQuote(p);
}
test('#1460 (R): emitted hook command is single-quoted when the install prefix contains a space', async () => {
const dir = runtimeWithSpace();
assert.ok(dir.includes(' '), 'precondition: install prefix contains a space');
const res = await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, sharedFiles: ['settings.json'],
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/format.sh' })),
});
assert.strictEqual(res.status, 'installed', JSON.stringify(res));
const command = readSettings(dir).hooks.PostToolUse[0].hooks[0].command;
const expectedAbs = expectedBundleCommand(dir, 'e', 'hooks/format.sh');
// revert-fails: without quoting the command is the bare space-containing absolute path,
// which a shell would word-split (the second token would be executed as a command).
assert.strictEqual(command, shQuote(expectedAbs), 'command must be POSIX single-quoted');
// Defense-in-depth: emulate POSIX word-splitting — single-quoted runs are atomic (whitespace
// inside them does NOT split). The quoted command must collapse to exactly ONE word (the path).
const words = command.match(/'[^']*'|[^\s']+/g) || [];
assert.strictEqual(words.length, 1, 'quoting must keep the space-containing path as a single shell word');
assert.strictEqual(words[0], command, 'the single word IS the entire quoted command');
// Negative control: the UNQUOTED path would split into >1 word at the space (the bug this prevents).
assert.ok((expectedAbs.match(/'[^']*'|[^\s']+/g) || []).length > 1, 'precondition: the bare path word-splits');
});
test('#1460 (R): a normal script under a normal prefix emits the quoted absolute command and stays strippable by CAP_MARKER', 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/run.js' })),
});
const before = readSettings(dir).hooks.PostToolUse;
assert.strictEqual(before.length, 1);
assert.strictEqual(before[0][CAP_MARKER], 'e');
// revert-fails: without quoting the control command is the bare absolute path.
assert.strictEqual(before[0].hooks[0].command, nodeQuoted(expectedBundleCommand(dir, 'e', 'hooks/run.js')));
// Strip is keyed on CAP_MARKER===capId, NOT the command string — quoting does not break it.
const rem = await lifecycle.removeCapability('e', {
runtimeDir: dir, hostVersion: '1.6.0', sharedFiles: ['settings.json'],
});
assert.strictEqual(rem.status, 'removed', JSON.stringify(rem));
const after = readSettings(dir);
assert.ok(!after || !after.hooks || !after.hooks.PostToolUse || after.hooks.PostToolUse.length === 0,
'the stamped hook is stripped by CAP_MARKER regardless of quoting');
});
test('#1460 (R): idempotent strip-then-reapply yields the identical quoted command', () => {
const dir = runtime();
const capId = 'e';
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(capDirPath, 'hooks', 'run.js'), '// x', 'utf8');
const manifest = execCap(capId, '1.0.0', { script: 'hooks/run.js' });
const apply = () => lifecycle.applyCapabilitySharedEdits({
runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'],
});
const edits = apply();
const first = readSettings(dir).hooks.PostToolUse;
// The real install/upgrade transition is strip-then-apply; re-running it must converge.
lifecycle.stripCapabilitySharedEdits({ runtimeDir: dir, capId, sharedEdits: edits });
apply();
const second = readSettings(dir).hooks.PostToolUse;
assert.strictEqual(second.length, 1, 'idempotent: still exactly one stamped hook after strip+reapply');
assert.strictEqual(second[0].hooks[0].command, first[0].hooks[0].command, 'identical quoted command on re-apply');
assert.strictEqual(second[0].hooks[0].command, nodeQuoted(expectedBundleCommand(dir, capId, 'hooks/run.js')));
});
// ---------------------------------------------------------------------------
// #1634: a declared tool-scoping `matcher` must be honored, and a .js-family
// hook command must run without relying on the source's executable bit.
// ---------------------------------------------------------------------------
test('#1634: a declared matcher is preserved on the emitted shared-config hook entry', () => {
const dir = runtime();
const capId = 'toolkit';
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(capDirPath, 'hooks', 'genfile-guard.cjs'), '// guard\n', 'utf8');
const manifest = declarativeCap(capId, '1.0.0');
manifest.hooks = [{ event: 'PreToolUse', script: 'hooks/genfile-guard.cjs', matcher: 'Write|Edit' }];
lifecycle.applyCapabilitySharedEdits({ runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'] });
const entry = readSettings(dir).hooks.PreToolUse[0];
assert.strictEqual(entry[CAP_MARKER], capId, 'entry is stamped with the capability marker');
// revert-fails: matcher was dropped, so the hook fired on every tool (Bash/Read/Write/Edit).
assert.strictEqual(entry.matcher, 'Write|Edit', 'declared matcher is preserved so the hook is tool-scoped');
});
test('#1634: an absent matcher is omitted (match-all) so shipped capabilities stay unchanged', () => {
const dir = runtime();
const capId = 'plain';
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(capDirPath, 'hooks', 'run.js'), '// x\n', 'utf8');
const manifest = declarativeCap(capId, '1.0.0');
manifest.hooks = [{ event: 'PostToolUse', script: 'hooks/run.js' }];
lifecycle.applyCapabilitySharedEdits({ runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'] });
const entry = readSettings(dir).hooks.PostToolUse[0];
assert.ok(!('matcher' in entry), 'no matcher declared => field omitted (match-all), preserving prior behavior');
});
test('#1634: a .cjs hook command is node-prefixed so it runs without the executable bit', () => {
const dir = runtime();
const capId = 'toolkit';
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
// Stage at 0644 (no +x) — a git/tarball source that lost the executable bit.
fs.writeFileSync(path.join(capDirPath, 'hooks', 'genfile-guard.cjs'), '// guard\n', { encoding: 'utf8', mode: 0o644 });
const manifest = declarativeCap(capId, '1.0.0');
manifest.hooks = [{ event: 'PreToolUse', script: 'hooks/genfile-guard.cjs' }];
lifecycle.applyCapabilitySharedEdits({ runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'] });
const command = readSettings(dir).hooks.PreToolUse[0].hooks[0].command;
// The executable bit is a POSIX concept; on Windows fs modes are not POSIX (a 0o644 write reads
// back as 0o666), so the precondition is checked on POSIX only. The node-prefix assertion below
// is the actual fix and is platform-independent.
if (process.platform !== 'win32') {
assert.strictEqual((fs.statSync(path.join(capDirPath, 'hooks', 'genfile-guard.cjs')).mode & 0o777), 0o644,
'precondition: file staged without +x');
}
// revert-fails: command was a bare single-quoted path -> /bin/sh: Permission denied on non-+x.
assert.ok(/^node '/.test(command), 'command is node-prefixed for a .cjs hook: ' + command);
});
test('#1634: matcher + node-prefix together; marker strip round-trip unaffected', () => {
const dir = runtime();
const capId = 'toolkit';
const capDirPath = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDirPath, 'hooks'), { recursive: true });
fs.writeFileSync(path.join(capDirPath, 'hooks', 'genfile-guard.cjs'), '// guard\n', 'utf8');
const manifest = declarativeCap(capId, '1.0.0');
manifest.hooks = [{ event: 'PreToolUse', script: 'hooks/genfile-guard.cjs', matcher: 'Write|Edit' }];
const edits = lifecycle.applyCapabilitySharedEdits({ runtimeDir: dir, capId, manifest, sharedFiles: ['settings.json'] });
const before = readSettings(dir).hooks.PreToolUse[0];
assert.strictEqual(before.matcher, 'Write|Edit', 'matcher preserved on apply');
assert.ok(/^node '/.test(before.hooks[0].command), 'command node-prefixed on apply');
// Strip is keyed on CAP_MARKER, not command/matcher shape — must still remove the entry.
lifecycle.stripCapabilitySharedEdits({ runtimeDir: dir, capId, sharedEdits: edits });
const after = readSettings(dir);
assert.ok(!after || !after.hooks || !after.hooks.PreToolUse || after.hooks.PreToolUse.length === 0,
'marker-stamped entry is stripped regardless of matcher/command shape');
});
test('#1460 (R): confinedBundleScript returns null for an unsafe-char script (defense-in-depth)', () => {
const dir = runtime();
const capDirPath = path.join(dir, '.gsd', 'capabilities', 'e');
fs.mkdirSync(capDirPath, { recursive: true });
// Even when the file literally exists on disk inside the bundle, an unsafe-char script
// name must be refused — so applyCapabilitySharedEdits skips it even if validation were
// bypassed. revert-fails: without the allowlist guard this returns the absolute path.
fs.writeFileSync(path.join(capDirPath, 'run.sh; touch pwn'), '// x', 'utf8');
assert.strictEqual(lifecycle.confinedBundleScript(capDirPath, 'run.sh; touch pwn'), null);
// A normal script still resolves to its confined absolute path.
fs.writeFileSync(path.join(capDirPath, 'ok.sh'), '// x', 'utf8');
const ok = lifecycle.confinedBundleScript(capDirPath, 'ok.sh');
assert.ok(typeof ok === 'string' && ok.endsWith(path.join('e', 'ok.sh')), 'normal script resolves: ' + ok);
});
// ---------------------------------------------------------------------------
// 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: write a ledger file directly (bypassing recordInstall's id validation, which now
// correctly rejects non-kebab ids — finding 7) to simulate an externally tampered ledger.
// The tampered entry uses a traversal id that reconcile must not act on.
const LEDGER_FILE_NAME = ledgerMod.LEDGER_FILE_NAME;
fs.writeFileSync(
path.join(dir, LEDGER_FILE_NAME),
JSON.stringify({
version: '1',
updatedAt: new Date().toISOString(),
entries: {
'../../precious': {
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');
});
// ---------------------------------------------------------------------------
// Finding 1: upgrade + remove with a corrupt-present ledger must fail closed
// (throw/quarantine), NOT silently return not_installed.
// ---------------------------------------------------------------------------
test('upgrade: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (issue-4)', async () => {
const dir = runtime();
// Write a corrupt ledger file (present but unparseable).
const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
const corruptContent = '{ broken json ---';
fs.writeFileSync(ledgerPath, corruptContent);
// upgradeCapability must NOT throw — it must return a blocked result.
let result;
await assert.doesNotReject(
async () => {
result = await lifecycle.upgradeCapability('./x', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('x', '2.0.0')),
});
},
'upgradeCapability must not throw on a corrupt ledger — must return a blocked result',
);
assert.strictEqual(result.status, 'blocked',
`upgradeCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)),
`blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`,
);
// The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1).
assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked upgrade');
assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged');
// No quarantine files must exist.
const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.'));
assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior');
});
test('remove: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (issue-4)', () => {
const dir = runtime();
const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
const corruptContent = '{ broken json ---';
fs.writeFileSync(ledgerPath, corruptContent);
// removeCapability must NOT throw — it must return a blocked result.
let result;
assert.doesNotThrow(
() => {
result = lifecycle.removeCapability('some-cap', { runtimeDir: dir });
},
'removeCapability must not throw on a corrupt ledger — must return a blocked result',
);
assert.strictEqual(result.status, 'blocked',
`removeCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)),
`blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`,
);
// The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1).
assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked remove');
assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged');
// No quarantine files must exist.
const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.'));
assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior');
});
// ---------------------------------------------------------------------------
// Finding 2 (HIGH): remove must run a READ-ONLY corruption preflight BEFORE acquireLock.
// On a corrupt ledger it must NOT create .gsd/capabilities and must NOT create a .lock.
// ---------------------------------------------------------------------------
test('finding-2: removeCapability on a corrupt ledger does NOT acquire a lock or create .gsd/capabilities (preflight precedes acquireLock)', () => {
const dir = runtime();
const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(ledgerPath, '{ broken json ---');
const capsRoot = path.join(dir, '.gsd', 'capabilities');
assert.ok(!fs.existsSync(capsRoot), 'precondition: .gsd/capabilities must not exist yet');
const result = lifecycle.removeCapability('some-cap', { runtimeDir: dir });
assert.strictEqual(result.status, 'blocked',
`removeCapability must be blocked on corrupt ledger; got: ${result?.status}`);
assert.ok(result.blockReasons && /corrupt|invalid/i.test(result.blockReasons.join(' ')),
`block reason must name corruption; got: "${(result.blockReasons || []).join(' ')}"`);
// UNCONDITIONAL: the read-only preflight failed BEFORE acquireLock, so no lock dir/file exists.
assert.ok(!fs.existsSync(capsRoot),
'.gsd/capabilities must NOT be created by acquireLock when the corruption preflight blocks first');
assert.ok(!fs.existsSync(path.join(capsRoot, '.lock')),
'no .lock file may be created when the corruption preflight blocks before acquireLock');
});
// ---------------------------------------------------------------------------
// Issue 1 (HIGH): wrong-shape sharedEdits/files member → readLedgerStrict quarantines
// → upgradeCapability/removeCapability return 'blocked', not a thrown error.
// ---------------------------------------------------------------------------
test('upgrade: wrong-shape sharedEdits member is treated as corrupt → status=blocked, not a thrown error (issue-1)', async () => {
const dir = runtime();
fs.mkdirSync(dir, { recursive: true });
// Write a ledger whose sharedEdits contains null — must fail deep validation.
const badLedger = {
version: '1',
updatedAt: new Date().toISOString(),
entries: {
x: {
id: 'x', version: '1.0.0', source: 'registry:test', integrity: 'sha256-abc',
files: [],
sharedEdits: [null], // null member — must fail deep validation
},
},
};
const ledgerFilePath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
const badContent = JSON.stringify(badLedger, null, 2);
fs.writeFileSync(ledgerFilePath, badContent);
let result;
await assert.doesNotReject(
async () => {
result = await lifecycle.upgradeCapability('./x', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('x', '2.0.0')),
});
},
'upgradeCapability must not throw on wrong-shape sharedEdits — must return blocked',
);
assert.strictEqual(result.status, 'blocked',
`must return status='blocked' for wrong-shape sharedEdits; got: ${result?.status}`);
// The corrupt file must still be in place (non-destructive — finding 1).
assert.ok(fs.existsSync(ledgerFilePath), 'malformed ledger must remain in place');
// No quarantine files must exist.
const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.'));
assert.strictEqual(quarantines.length, 0, 'no quarantine files — non-destructive');
});
test('remove: wrong-shape files member is treated as corrupt → status=blocked, not a thrown error (issue-1)', () => {
const dir = runtime();
fs.mkdirSync(dir, { recursive: true });
// Write a ledger whose files[] contains a number — must fail deep validation.
const badLedger = {
version: '1',
updatedAt: new Date().toISOString(),
entries: {
'some-cap': {
id: 'some-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-abc',
files: [123], // non-string — must fail deep validation
sharedEdits: [],
},
},
};
const ledgerFilePath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
fs.writeFileSync(ledgerFilePath, JSON.stringify(badLedger, null, 2));
let result;
assert.doesNotThrow(
() => {
result = lifecycle.removeCapability('some-cap', { runtimeDir: dir });
},
'removeCapability must not throw on wrong-shape files[] — must return blocked',
);
assert.strictEqual(result.status, 'blocked',
`must return status='blocked' for wrong-shape files[]; got: ${result?.status}`);
// The malformed ledger must remain in place (non-destructive — finding 1).
assert.ok(fs.existsSync(ledgerFilePath), 'malformed ledger must remain in place');
const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.'));
assert.strictEqual(quarantines.length, 0, 'no quarantine files — non-destructive');
});
// ---------------------------------------------------------------------------
// 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);
});
// ---------------------------------------------------------------------------
// #1460 CONF-1 — a hook command is written as the ABSOLUTE path inside the
// capability's own install dir, confined via realpath; a script that resolves
// OUTSIDE the bundle is NOT written.
// revert-fails: with the raw `command: script` restored (pre-fix), the absolute
// assertion fails and a bundle-escaping script would be written.
// ---------------------------------------------------------------------------
test('#1460 CONF-1: a relative hook script is emitted as the absolute path inside capDir', () => {
const dir = runtime();
const capId = 'conf1';
// Make the install dir real so realpath confinement resolves a concrete chain.
const capDir = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(path.join(capDir, 'subdir'), { recursive: true });
fs.writeFileSync(path.join(capDir, 'subdir', 'run.js'), '// hook', 'utf8');
lifecycle.applyCapabilitySharedEdits({
runtimeDir: dir,
capId,
manifest: { hooks: [{ event: 'PostToolUse', script: 'subdir/run.js' }] },
sharedFiles: ['settings.json'],
});
const s = readSettings(dir);
const command = s.hooks.PostToolUse[0].hooks[0].command;
const expectedAbs = path.join(fs.realpathSync(path.join(capDir, 'subdir')), 'run.js');
// #1460 (R) + #1634: the emitted command is `node ` + the absolute confined path, POSIX single-quoted.
assert.strictEqual(command, nodeQuoted(expectedAbs), 'command must be node + the quoted absolute confined path inside capDir');
const unquoted = command.slice('node '.length + 1, -1); // drop `node ` prefix, strip the wrapping single quotes
assert.ok(path.isAbsolute(unquoted), 'command path must be absolute (CWD-independent)');
assert.ok(unquoted.startsWith(fs.realpathSync(capDir) + path.sep), 'command path must live inside the bundle');
});
test('#1460 CONF-1: a script resolving OUTSIDE capDir via a symlinked subdir is NOT written (skipped)', (t) => {
const dir = runtime();
const capId = 'conf1-escape';
const capDir = path.join(dir, '.gsd', 'capabilities', capId);
fs.mkdirSync(capDir, { recursive: true });
// Plant a victim file outside the bundle and a symlinked subdir inside the bundle
// that points at the victim's parent. A relative script "evil/run.js" would then
// resolve to the victim through the symlink — confinement must refuse it.
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'conf1-outside-'));
cleanups.push(outside);
fs.writeFileSync(path.join(outside, 'run.js'), '// victim', 'utf8');
try {
fs.symlinkSync(outside, path.join(capDir, 'evil'));
} catch {
t.skip('symlink not supported on this platform');
return;
}
lifecycle.applyCapabilitySharedEdits({
runtimeDir: dir,
capId,
manifest: { hooks: [{ event: 'PostToolUse', script: 'evil/run.js' }] },
sharedFiles: ['settings.json'],
});
const s = readSettings(dir);
// The escaping hook is skipped → no settings written at all (no touched edits).
assert.strictEqual(s, null, 'no shared-config edit must be written for a bundle-escaping script');
});
// ---------------------------------------------------------------------------
// #1460 CONF-2 — REGRESSION GUARD: confinedSharedFile realpaths the FULL ancestor
// chain, so an ANCESTOR symlink (not just the final component) cannot escape.
// revert-fails: if confinedSharedFile were changed to realpath only the final
// component, a path through a symlinked ancestor would resolve OUTSIDE runtimeDir
// and this test (asserting null) would fail.
// ---------------------------------------------------------------------------
test('#1460 CONF-2: confinedSharedFile refuses a path through a symlinked ANCESTOR directory', (t) => {
const dir = runtime();
// Build runtimeDir/inner where `inner` is a symlink to a directory OUTSIDE runtimeDir.
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'conf2-outside-'));
cleanups.push(outside);
fs.mkdirSync(path.join(outside, 'deep'), { recursive: true });
try {
fs.symlinkSync(outside, path.join(dir, 'inner'));
} catch {
t.skip('symlink not supported on this platform');
return;
}
// A path whose ANCESTOR ("inner") is the escaping symlink — the final component
// ("settings.json") is not itself a link, so a final-component-only realpath would
// miss the escape. confinedSharedFile realpaths the parent chain and must return null.
const result = lifecycle.confinedSharedFile(dir, path.join('inner', 'deep', 'settings.json'));
assert.strictEqual(result, null, 'a path through a symlinked ancestor must be refused (null)');
});
// ---------------------------------------------------------------------------
// Site B: reconcileCapabilities on a corrupt-present ledger must surface a
// warning in its report, not silently do nothing (#1462).
// ---------------------------------------------------------------------------
test('reconcileCapabilities: a corrupt-present ledger surfaces a warning in report.warnings (site B)', () => {
const dir = runtime();
fs.mkdirSync(dir, { recursive: true });
// Write a corrupt (unparseable) ledger file.
fs.writeFileSync(path.join(dir, ledgerMod.LEDGER_FILE_NAME), '{ broken json ---');
let report;
assert.doesNotThrow(
() => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); },
'reconcileCapabilities must not throw on a corrupt ledger',
);
assert.ok(report, 'must return a report object');
// The warning must be in the top-level report.warnings[] field (not only nested
// under report.ledger.warnings which is typed as unknown and callers miss it).
assert.ok(Array.isArray(report.warnings),
`report.warnings must be an array; got: ${typeof report.warnings}`);
assert.ok(
report.warnings.some((w) => /corrupt|could not be parsed/i.test(w)),
`report.warnings must contain a warning mentioning corruption; got: ${JSON.stringify(report.warnings)}`,
);
});
// ---------------------------------------------------------------------------
// Finding 2 (HIGH): reconcile must run a READ-ONLY corruption preflight BEFORE acquireLock.
// On a corrupt ledger it must warn WITHOUT creating .gsd/capabilities or a .lock.
// ---------------------------------------------------------------------------
test('finding-2: reconcileCapabilities on a corrupt ledger does NOT acquire a lock or create .gsd/capabilities (preflight precedes acquireLock)', () => {
const dir = runtime();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, ledgerMod.LEDGER_FILE_NAME), '{ broken json ---');
const capsRoot = path.join(dir, '.gsd', 'capabilities');
assert.ok(!fs.existsSync(capsRoot), 'precondition: .gsd/capabilities must not exist yet');
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
assert.ok(report.warnings.some((w) => /corrupt|could not be parsed/i.test(w)),
`report.warnings must mention corruption; got: ${JSON.stringify(report.warnings)}`);
// UNCONDITIONAL: the read-only preflight warned BEFORE acquireLock, so no lock dir/file exists.
assert.ok(!fs.existsSync(capsRoot),
'.gsd/capabilities must NOT be created by acquireLock when the corruption preflight warns first');
assert.ok(!fs.existsSync(path.join(capsRoot, '.lock')),
'no .lock file may be created when the corruption preflight warns before acquireLock');
});
// ---------------------------------------------------------------------------
// Issue HIGH: installCapability corrupt-ledger fail-closed (Codex pass 3)
// Prior-entry readLedger (non-strict) + uncaught recordInstall CorruptLedgerError
// — both paths must return blocked, never throw.
// ---------------------------------------------------------------------------
test('install: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (codex-p3-h1)', async () => {
const dir = runtime();
const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME);
fs.mkdirSync(dir, { recursive: true });
const corruptContent = '{ broken json ---';
fs.writeFileSync(ledgerPath, corruptContent);
// installCapability must NOT throw — it must return a blocked result.
let result;
await assert.doesNotReject(
async () => {
result = await lifecycle.installCapability('./newcap', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('newcap', '1.0.0')),
});
},
'installCapability must not throw on a corrupt ledger — must return a blocked result',
);
assert.strictEqual(result.status, 'blocked',
`installCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)),
`blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`,
);
// The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1).
assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked install');
assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged');
// No quarantine files must exist.
const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.'));
assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior');
});
// ---------------------------------------------------------------------------
// Finding 3: removeCapability ledger-write failure after files deleted → blocked result,
// no unhandled throw. Coherent state: files gone, ledger still references them (retry-able).
// ---------------------------------------------------------------------------
test('remove: ledger commit failure after files are deleted returns blocked (finding-3)', async (t) => {
const dir = runtime();
// Install a capability.
await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('e')),
});
assert.ok(ledgerMod.readLedger(dir)?.entries['e'], 'e must be installed');
// Mock renameSync to fail (ledger write = tmp+rename; make the rename fail).
const { mock } = require('node:test');
const renameMock = mock.method(require('node:fs'), 'renameSync', (_src, _dest) => {
const err = new Error('EXDEV: cross-device rename not permitted');
err.code = 'EXDEV';
throw err;
});
t.after(() => renameMock.mock.restore());
// removeCapability must NOT throw — must return a blocked result.
let result;
assert.doesNotThrow(
() => { result = lifecycle.removeCapability('e', { runtimeDir: dir }); },
'removeCapability must not throw when ledger commit fails',
);
assert.strictEqual(result.status, 'blocked',
`must return blocked when ledger write fails; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.some((r) => /ledger commit failed|EXDEV/i.test(r)),
`blockReasons must mention ledger commit failure; got: ${JSON.stringify(result?.blockReasons)}`,
);
});
// ---------------------------------------------------------------------------
// Finding 4: upgradeCapability intent-write failure → blocked result, no unhandled throw.
// ---------------------------------------------------------------------------
test('upgrade: intent recordInstall failure returns blocked (finding-4)', async (t) => {
const dir = runtime();
// Install first.
await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('e', '1.0.0')),
});
// Mock renameSync to fail (writeLedger uses tmp+rename; the intent write will fail).
const { mock } = require('node:test');
const renameMock = mock.method(require('node:fs'), 'renameSync', (_src, _dest) => {
const err = new Error('EXDEV: cross-device rename not permitted');
err.code = 'EXDEV';
throw err;
});
t.after(() => renameMock.mock.restore());
// upgradeCapability must NOT throw — must return blocked.
let result;
await assert.doesNotReject(
async () => {
result = await lifecycle.upgradeCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('e', '2.0.0')),
});
},
'upgradeCapability must not throw when intent write fails',
);
assert.strictEqual(result.status, 'blocked',
`must return blocked when upgrade intent write fails; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.length > 0,
`must have blockReasons; got: ${JSON.stringify(result?.blockReasons)}`,
);
});
// ---------------------------------------------------------------------------
// 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');
});
// ---------------------------------------------------------------------------
// Finding 3 (HIGH): removeCapability must commit from the ALREADY-read in-memory
// ledger — not re-read via removeEntry's non-strict readLedger. A ledger corrupted
// between the strict pre-read and the commit must not produce a silent 'removed'
// result with dangling refs.
// ---------------------------------------------------------------------------
test('remove: finding-3 — removeCapability NEVER calls ledgerMod.removeEntry (uses in-memory writeLedger, not a re-read path)', async (t) => {
const dir = runtime();
// Install a capability so it exists in the ledger.
await lifecycle.installCapability('./rf3', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('rf3')),
});
assert.ok(readLedgerEntry(dir, 'rf3'), 'rf3 must be installed before remove test');
// Spy on ledgerMod.removeEntry — removeCapability must NEVER call it.
// (removeCapability commits by mutating the in-memory ledger + writeLedger directly,
// never via removeEntry whose re-read is non-strict and would silently swallow corruption.)
const { mock } = require('node:test');
let removeEntryCalls = 0;
const removeEntryMock = mock.method(ledgerMod, 'removeEntry', function (...args) {
removeEntryCalls++;
// Still call through so ledger stays consistent if the code ever uses it.
return ledgerMod.removeEntry.__origFn ? ledgerMod.removeEntry.__origFn(...args) : undefined;
});
t.after(() => removeEntryMock.mock.restore());
const result = lifecycle.removeCapability('rf3', { runtimeDir: dir });
assert.strictEqual(result.status, 'removed',
'removeCapability must succeed using the in-memory writeLedger path');
assert.strictEqual(removeEntryCalls, 0,
'removeCapability must NEVER call ledgerMod.removeEntry — it must commit via the in-memory writeLedger path');
assert.strictEqual(readLedgerEntry(dir, 'rf3'), null, 'entry must be gone after remove');
});
test('remove: finding-3 — mid-remove corruption blocks coherently: capability files gone but ledger write fails → blocked (not silent removed with dangling refs)', async (t) => {
const dir = runtime();
// Install a capability.
await lifecycle.installCapability('./rf3b', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('rf3b')),
});
assert.ok(readLedgerEntry(dir, 'rf3b'), 'rf3b must be installed');
// After strict pre-read, corrupt the ledger on disk so the writeLedger commit fails.
// We do this by intercepting the SECOND renameSync call (the atomic ledger write's rename)
// with an EXDEV error, simulating a commit failure after files are already deleted.
const { mock } = require('node:test');
const realRename = fs.renameSync.bind(fs);
let renameCount = 0;
const renameMock = mock.method(fs, 'renameSync', function (src, dst) {
renameCount++;
// The first rename may be for staging during install setup; skip it.
// The ledger commit rename will be for a .tmp.<pid>-<nonce> → .gsd-capabilities.json path.
if (typeof dst === 'string' && dst.includes('.gsd-capabilities.json') && renameCount >= 1) {
const err = new Error('EXDEV: cross-device link not permitted');
err.code = 'EXDEV';
throw err;
}
return realRename(src, dst);
});
t.after(() => renameMock.mock.restore());
const result = lifecycle.removeCapability('rf3b', { runtimeDir: dir });
// The result must be 'blocked' — not 'removed' — because the ledger commit failed.
// Returning 'removed' when the ledger write failed would be a "silent removed with dangling refs".
assert.strictEqual(result.status, 'blocked',
`removeCapability must return blocked when the ledger commit fails; got: ${result?.status}`);
assert.ok(
result.blockReasons && result.blockReasons.length > 0,
'must include blockReasons explaining the failure',
);
// The error message must NOT reference a non-existent CLI command ('gsd capability reconcile').
const reason = result.blockReasons[0] || '';
assert.ok(
!reason.includes('gsd capability reconcile'),
`blockReasons must not reference non-existent CLI subcommand 'gsd capability reconcile'; got: "${reason}"`,
);
});
// ---------------------------------------------------------------------------
// ROOT FIX 2: preflight strict-read BEFORE source resolution + staging.
// A corrupt ledger must block install/upgrade BEFORE any staging dir is created.
// ---------------------------------------------------------------------------
test('root-fix-2: install on a corrupt-present ledger blocks BEFORE resolving source / creating staging', async (_t) => {
const dir = runtime();
// Write a corrupt ledger before attempting install.
const ledgerPath = path.join(dir, '.gsd-capabilities.json');
fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8');
let resolveCalled = false;
const trackingResolve = async (spec, opts) => {
resolveCalled = true;
// Materialize a staging dir so a regression (resolve before strict read) is observable.
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, 'preflight-test');
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(
{ id: 'pf', role: 'feature', version: '1.0.0', title: 'pf', description: 'x',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] }
), 'utf8');
return { id: 'pf', version: '1.0.0', stagedDir: staged, integrity: null, source: spec };
};
const result = await lifecycle.installCapability('./pf', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: trackingResolve,
});
assert.strictEqual(result.status, 'blocked',
'installCapability must be blocked by a corrupt ledger');
assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons');
// The block reason must be the CORRUPTION (not some downstream staging/consent message).
assert.ok(
/corrupt|invalid/i.test(result.blockReasons.join(' ')),
`block reason must name ledger corruption; got: "${result.blockReasons.join(' ')}"`,
);
// UNCONDITIONAL invariant: the strict read precedes source resolution, so the resolver is
// NEVER invoked when the ledger is corrupt. (Was gated behind `if (!resolveCalled)` — vacuous.)
assert.strictEqual(resolveCalled, false,
'resolver must NOT be called when the ledger is corrupt (strict read precedes _resolve)');
// And because resolve never ran, NO staging dir was created.
const stagingRoot = path.join(dir, '.gsd', 'capabilities', '.staging');
assert.ok(
!fs.existsSync(stagingRoot),
'no .staging dir may be created when the ledger is corrupt (preflight precedes staging)',
);
});
test('root-fix-2: upgrade on a corrupt-present ledger blocks BEFORE resolving source / creating staging', async (_t) => {
const dir = runtime();
// First install successfully.
await lifecycle.installCapability('./u2', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('u2', '1.0.0')),
});
// Then corrupt the ledger.
const ledgerPath = path.join(dir, '.gsd-capabilities.json');
fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8');
let resolveCalled = false;
const trackingResolve = async (spec, opts) => {
resolveCalled = true;
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, 'preflight-upgrade-test');
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(
{ id: 'u2', role: 'feature', version: '2.0.0', title: 'u2', description: 'x',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] }
), 'utf8');
return { id: 'u2', version: '2.0.0', stagedDir: staged, integrity: null, source: spec };
};
// Snapshot the .staging dir's prior contents (the successful install above may have left none,
// but be precise): the corrupt-upgrade attempt must add NOTHING.
const stagingRoot = path.join(dir, '.gsd', 'capabilities', '.staging');
const before = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot).sort() : [];
const result = await lifecycle.upgradeCapability('./u2-v2', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: trackingResolve,
});
assert.strictEqual(result.status, 'blocked',
'upgradeCapability must be blocked by a corrupt ledger');
assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons');
assert.ok(
/corrupt|invalid/i.test(result.blockReasons.join(' ')),
`block reason must name ledger corruption; got: "${result.blockReasons.join(' ')}"`,
);
// UNCONDITIONAL: strict read precedes resolution, so the resolver is never invoked.
assert.strictEqual(resolveCalled, false,
'resolver must NOT be called when the ledger is corrupt (strict read precedes _resolve)');
// No NEW staging entry was created by the corrupt-upgrade attempt.
const after = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot).sort() : [];
assert.deepStrictEqual(after, before,
'no new .staging entry may be created when the ledger is corrupt (preflight precedes staging)');
});
test('root-fix-2: corrupt-ledger EXECUTABLE install WITHOUT --yes blocks on CORRUPTION (not aborts on consent)', async (_t) => {
const dir = runtime();
// Write a corrupt ledger before attempting install.
const ledgerPath = path.join(dir, '.gsd-capabilities.json');
fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8');
let resolveCalled = false;
const trackingResolve = async (spec, opts) => {
resolveCalled = true;
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, 'exec-corrupt-test');
fs.mkdirSync(staged, { recursive: true });
// An EXECUTABLE capability (declares a hook) — would normally require consent.
const manifest = execCap('execcap', '1.0.0', { script: 'hooks/run.js' });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(manifest), 'utf8');
materialize(staged, 'hooks/run.js');
return { id: 'execcap', version: '1.0.0', stagedDir: staged, integrity: null, source: spec };
};
// No consentGranted (i.e. no --yes). Pre-fix order returned 'aborted' (consent) BEFORE the
// strict read at ~660 ever ran, masking the corruption. Post-fix: corruption is detected FIRST.
const result = await lifecycle.installCapability('./execcap', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: trackingResolve,
// consentGranted intentionally omitted (falsy) — this is the WITHOUT --yes case.
});
assert.strictEqual(result.status, 'blocked',
`corrupt-ledger executable install without --yes must be 'blocked' on corruption, ` +
`not 'aborted' on consent; got: ${result.status}`);
assert.notStrictEqual(result.status, 'aborted',
'must NOT report aborted-on-consent before the corruption is reported');
assert.ok(result.blockReasons && /corrupt|invalid/i.test(result.blockReasons.join(' ')),
`block reason must name ledger corruption; got: "${(result.blockReasons || []).join(' ')}"`);
assert.strictEqual(resolveCalled, false,
'resolver must NOT be called — corruption is detected before resolution/consent');
});
// ---------------------------------------------------------------------------
// ROOT FIX 3: unsafe capability ids rejected at install/upgrade before staging.
// A __proto__/constructor/prototype bundle must never be promoted.
// ---------------------------------------------------------------------------
test('root-fix-3: installCapability rejects unsafe id (constructor) before staging/promotion', async (_t) => {
const dir = runtime();
const result = await lifecycle.installCapability('./evil', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: async (spec, opts) => {
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, 'unsafe-id-test');
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(
{ id: 'constructor', role: 'feature', version: '1.0.0', title: 'evil',
description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] }
), 'utf8');
return { id: 'constructor', version: '1.0.0', stagedDir: staged, integrity: null, source: spec };
},
});
assert.strictEqual(result.status, 'blocked',
`installCapability must block a capability with id='constructor'; got: ${result.status}`);
assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons');
// Must NOT have installed a bundle at .gsd/capabilities/constructor
assert.ok(
!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'constructor')),
'no .gsd/capabilities/constructor bundle must be promoted',
);
// Must NOT have a ledger entry for 'constructor'
const l = ledgerMod.readLedger(dir);
assert.ok(!l || !Object.prototype.hasOwnProperty.call(l.entries, 'constructor'),
'no ledger entry for id=constructor must exist');
});
test('root-fix-3: installCapability rejects __proto__ and prototype ids', async (_t) => {
const dir = runtime();
for (const unsafeId of ['__proto__', 'prototype']) {
const res = await lifecycle.installCapability('./evil', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: async (spec, opts) => {
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, `unsafe-${unsafeId}`);
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(
{ id: unsafeId, role: 'feature', version: '1.0.0', title: 'evil',
description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] }
), 'utf8');
return { id: unsafeId, version: '1.0.0', stagedDir: staged, integrity: null, source: spec };
},
});
assert.strictEqual(res.status, 'blocked',
`installCapability must block id='${unsafeId}'; got: ${res.status}`);
}
});
test('root-fix-3: upgradeCapability rejects unsafe id (constructor) before staging/promotion', async (_t) => {
const dir = runtime();
// Install a safe version first.
await lifecycle.installCapability('./safe', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('safe-cap', '1.0.0')),
});
// Attempt an upgrade where the resolved id is 'constructor' (source retargeted to unsafe id).
const result = await lifecycle.upgradeCapability('./evil-upgrade', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: async (spec, opts) => {
const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging');
fs.mkdirSync(root, { recursive: true });
const staged = path.join(root, 'unsafe-upgrade-test');
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(
{ id: 'constructor', role: 'feature', version: '2.0.0', title: 'evil',
description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] }
), 'utf8');
return { id: 'constructor', version: '2.0.0', stagedDir: staged, integrity: null, source: spec };
},
});
assert.strictEqual(result.status, 'blocked',
`upgradeCapability must block unsafe id='constructor'; got: ${result.status}`);
assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'constructor')),
'no .gsd/capabilities/constructor bundle must be promoted on upgrade');
});
// ---------------------------------------------------------------------------
// FIX 5: removeCapability failure guidance must NOT reference 'gsd capability reconcile'
// (a non-existent CLI subcommand).
// ---------------------------------------------------------------------------
test('fix-5: removeCapability commit-fail guidance does not reference nonexistent "gsd capability reconcile" subcommand', async (t) => {
const dir = runtime();
// Install a capability.
await lifecycle.installCapability('./fix5cap', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('fix5cap')),
});
// Simulate commit failure by making the ledger write's renameSync throw.
const { mock } = require('node:test');
const realRename = fs.renameSync.bind(fs);
const renameMock = mock.method(fs, 'renameSync', function (src, dst) {
if (typeof dst === 'string' && dst.includes('.gsd-capabilities.json')) {
const err = new Error('EXDEV: cross-device link not permitted');
err.code = 'EXDEV';
throw err;
}
return realRename(src, dst);
});
t.after(() => renameMock.mock.restore());
const result = lifecycle.removeCapability('fix5cap', { runtimeDir: dir });
assert.strictEqual(result.status, 'blocked');
const reason = (result.blockReasons || []).join(' ');
assert.ok(
!reason.includes('gsd capability reconcile'),
`Failure guidance must not reference non-existent "gsd capability reconcile"; got: "${reason}"`,
);
// Must still mention a useful recovery action (inspect/restore the ledger file).
assert.ok(
reason.includes('ledger') || reason.includes('.gsd-capabilities.json') || reason.includes('remove'),
`Failure guidance must mention the ledger or re-run remove; got: "${reason}"`,
);
});
// ===========================================================================
// Orthogonal adversarial review (#1462) — durability / concurrency / Windows /
// DoS / UX cross-cutting findings. TDD red-first.
// ===========================================================================
// ---------------------------------------------------------------------------
// Finding 1 (HIGH): lock liveness via PROCESS START-TIME. The lock has oscillated
// (age-based→lost-update; pid-liveness→pid-reuse-deadlock; deadman→live-steal).
// The convergent design records THIS process's start-time in the lock body and,
// on the steal-decision path, treats a SAME-host holder as live ONLY if its pid
// is alive AND the pid's CURRENT start-time matches the recorded one. That pair
// (pid, start-time) identifies a process INSTANCE, so pid-reuse is detected as a
// start-time MISMATCH and stolen, while a verified-live holder is NEVER stolen
// (even past the deadman). A DIFFERENT host / unparseable-or-no-pid body can't be
// verified locally → stolen only by the deadman fallback. A fresh lock is never
// stolen.
//
// Tests inject DETERMINISTIC isPidAlive / getProcessStartTime via the exported
// _setLockProbes seam so the start-time branches are exercised without depending on
// real OS pids beyond the current process. Every test resets the probes in t.after.
// ---------------------------------------------------------------------------
/**
* Build a JSON lock body matching the new lockfile shape. `startTime` is included by default; pass
* `startTime: null` to simulate a body that did not record one (legacy-ish / unverifiable).
*/
function lockBody({ pid = process.pid, host = os.hostname(), ts = Date.now(), startTime = 'START-A' } = {}) {
// First `-`-segment is still the pid (legacy token compatibility); the JSON body carries host + startTime.
return JSON.stringify({ token: `${pid}-${ts}-1`, pid, hostname: host, startTime, ts });
}
function writeLock(dir, body, ageMs) {
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Finding 1: age now binds to the BODY's own `ts` for a JSON body (not the file mtime). So a lock
// that is `ageMs` old must carry a `ts` that is `ageMs` in the past — backdate BOTH the body ts (for
// JSON bodies) AND the file mtime (for legacy/no-ts bodies, which still use the mtime fallback).
let written = body;
if (typeof body === 'string' && body.trim().startsWith('{')) {
try {
const obj = JSON.parse(body);
if (obj && typeof obj === 'object' && 'ts' in obj) {
obj.ts = Date.now() - ageMs; // backdate the body's own timestamp to the intended age.
written = JSON.stringify(obj);
}
} catch { /* not JSON after all — write verbatim */ }
}
fs.writeFileSync(lockPath, written, 'utf8');
const t = new Date(Date.now() - ageMs);
fs.utimesSync(lockPath, t, t);
return lockPath;
}
/** Install deterministic lock probes and auto-reset them after the test. */
function withLockProbes(t, { alive, startTime }) {
lifecycle._setLockProbes({ isPidAlive: () => alive, getProcessStartTime: () => startTime });
t.after(() => lifecycle._resetLockProbes());
}
test('finding-1: acquireLock is exported (used by lock unit tests)', () => {
assert.ok(typeof lifecycle.acquireLock === 'function', 'acquireLock must be exported for testing');
assert.ok(typeof lifecycle._setLockProbes === 'function', '_setLockProbes seam must be exported');
assert.ok(typeof lifecycle.getProcessStartTime === 'function', 'getProcessStartTime must be exported');
});
// Revert-fails: drop the start-time match from holderVerifiedLive (treat any live pid as live) →
// the verified-live SAME-host holder past the deadman is no longer protected and gets stolen, so
// acquireLock returns a handle and this strictEqual(null) assertion fails.
test('finding-1: a SAME-host VERIFIED-LIVE holder (pid alive + start-time MATCH) is NOT stolen — even past the deadman', (t) => {
const dir = runtime();
// pid alive AND its observed start-time equals the recorded one → verified-live → sacrosanct.
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 11 * 60 * 1000);
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'a verified-live same-host holder must NEVER be stolen, even past the deadman');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched');
});
// Same protection must hold UNDER the deadman too (the older deadman-only design would also block
// here, but this guards the explicit "verified-live → blocked" branch under the stale window).
// Revert-fails: same as above — drop the start-time match → stolen → handle non-null → fails.
test('finding-1: a SAME-host VERIFIED-LIVE holder (start-time MATCH), stale but under the deadman, is NOT stolen', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000);
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'a stale-but-verified-live same-host holder must NOT be stolen');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched');
});
// Revert-fails: drop the start-time MISMATCH check in holderVerifiedLive (return true on any live
// pid) → the pid-reuse lock (alive pid, but a DIFFERENT current start-time) is treated as live and
// NOT stolen, so acquireLock returns null and this "must be stolen" assertion fails — the permanent
// pid-reuse deadlock the whole design exists to defeat.
test('finding-1: a SAME-host pid-reuse holder (pid alive but start-time MISMATCH) IS stolen after stale', (t) => {
const dir = runtime();
// pid is "alive" but its CURRENT start-time differs from the recorded one → reuse → steal-eligible.
withLockProbes(t, { alive: true, startTime: 'NEW-START-after-reuse' });
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'OLD-START-before-crash' }), 2 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a same-host lock whose pid is alive but whose start-time no longer matches (pid-reuse) must be stolen');
assert.strictEqual(JSON.parse(fs.readFileSync(lockPath, 'utf8')).token, handle.token,
'the stolen lock body must now carry OUR token');
lifecycle.releaseLock(handle);
});
// Revert-fails: drop the dead-pid steal (require the deadman for same-host) → a demonstrably-dead
// local holder past the stale window but under the deadman is never stolen, so acquireLock returns
// null and this "must be stolen" assertion fails.
test('finding-1: a SAME-host stale (>60s, <deadman) lock with a DEAD pid is stolen (fast local recovery)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: false, startTime: 'START-A' }); // pid dead → not verified-live
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a same-host stale lock whose pid is dead must be stolen before the deadman timeout');
lifecycle.releaseLock(handle);
});
// Revert-fails: drop the "recorded.startTime != null" requirement (treat a live pid with no recorded
// start-time as verified-live) → a same-host live-pid lock that recorded NO start-time would be
// blocked and never stolen, so this "must be stolen" assertion fails. A start-time we cannot verify
// is NOT verified-live.
test('finding-1: a SAME-host live-pid lock with NO recorded start-time is stolen after stale (unverifiable liveness)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' }); // pid "alive" but body recorded no start-time
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: null }), 2 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a same-host live-pid lock whose body recorded no start-time cannot be verified-live → stolen after stale');
lifecycle.releaseLock(handle);
});
// Revert-fails: drop the "observed start-time unobtainable → not live" handling (return true when
// getProcessStartTime is null) → a live-pid lock whose CURRENT start-time can't be read would be
// blocked and never stolen, so this "must be stolen" assertion fails.
test('finding-1: a SAME-host live-pid lock whose CURRENT start-time is unobtainable is stolen after stale', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: null }); // can't observe a current start-time
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a same-host live-pid lock whose current start-time is unobtainable cannot be verified-live → stolen');
lifecycle.releaseLock(handle);
});
// Revert-fails: route a legacy (no-pid) body into the same-host-pid branch (or steal it before the
// deadman) → a legacy lock under the deadman would be stolen, so this strictEqual(null) fails.
// A no-pid body is unverifiable → only the deadman may reclaim it.
test('finding-1: a stale legacy (no parseable pid) lock UNDER the deadman is NOT stolen (deadman-only)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = writeLock(dir, 'legacy-token-no-pid', 2 * 60 * 1000); // non-JSON, no pid
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'a no-pid legacy lock under the deadman cannot be verified and must NOT be stolen yet');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the legacy lock body must be untouched');
});
// Revert-fails: drop the deadman fallback for the no-pid branch → a legacy lock past the deadman is
// never reclaimed (permanent deadlock), so acquireLock returns null and this "must be stolen" fails.
test('finding-1: a stale legacy (no parseable pid) lock OLDER than the deadman IS stolen (deadman defeats deadlock)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
writeLock(dir, 'legacy-token-no-pid', 11 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token, 'a no-pid legacy lock past the deadman must be stolen');
lifecycle.releaseLock(handle);
});
// Revert-fails: drop the DIFFERENT-host handling (judge any host by local pid liveness) → a remote
// lock under the deadman gets stolen via a local pid that happens to be alive, so this
// strictEqual(null) assertion fails. Local pid liveness is meaningless cross-host.
test('finding-1: a DIFFERENT-host stale (<deadman) lock is NOT stolen (local pid is meaningless cross-host)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() + '-OTHER-HOST' }), 2 * 60 * 1000);
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'a different-host lock under the deadman must NOT be stolen (local pid liveness is meaningless cross-host)');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the cross-host lock body must be untouched');
});
// Revert-fails: drop the deadman branch for the different-host case → a remote lock past the deadman
// is never reclaimed, so acquireLock returns null and this "must be stolen" assertion fails.
test('finding-1: a DIFFERENT-host lock OLDER than the deadman timeout IS stolen', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() + '-OTHER-HOST' }), 11 * 60 * 1000);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a different-host lock older than LOCK_DEADMAN_MS must be stolen (deadman defeats cross-host deadlock)');
lifecycle.releaseLock(handle);
});
// Revert-fails: remove the fresh-lock short-circuit (age <= LOCK_STALE_MS) → a 1-second-old lock
// would be evaluated for stealing and (with a dead pid) stolen, so this strictEqual(null) fails.
test('finding-1: a FRESH lock (under the stale window) is never stolen regardless of host/pid', (t) => {
const dir = runtime();
withLockProbes(t, { alive: false, startTime: 'START-A' }); // even a dead pid must not matter while fresh
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() }), 1000);
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'a fresh lock must never be stolen');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'fresh lock body untouched');
});
// Finding 2 (MEDIUM): the lock body is untrusted. An OVERSIZED lock body must NOT be read whole; it
// is treated as unparseable (no pid/host) → routed to the deadman policy.
//
// The oversized body is crafted so that, IF it were (wrongly) read, it would parse as a SAME-host,
// alive-pid holder with a MISMATCHED start-time (pid-reuse) → which is steal-eligible after stale. So
// WITHOUT the size cap the lock would be STOLEN (handle non-null); WITH the cap it is treated as
// no-pid → NOT stolen under the deadman (handle null). The `strictEqual(null)` assertion therefore
// holds ONLY when the cap is in effect — a true discriminator, not a vacuous pass.
//
// Revert-fails: drop the statSync size-cap in readParsedLockBounded (read the whole body) → the body
// parses as an alive same-host holder with a mismatched start-time and is STOLEN, so acquireLock
// returns a handle and this strictEqual(null) assertion fails.
test('finding-2: an OVERSIZED lock body is treated as unparseable (no pid) and NOT stolen under the deadman', (t) => {
const dir = runtime();
// pid "alive" but the OBSERVED start-time differs from the recorded one → if the body were read it
// would look like steal-eligible pid-reuse. The size cap must prevent that read entirely.
withLockProbes(t, { alive: true, startTime: 'OBSERVED-NEW' });
const huge = JSON.stringify({ token: 't', pid: process.pid, hostname: os.hostname(), startTime: 'RECORDED-OLD', ts: Date.now(), pad: 'x'.repeat(70 * 1024) });
assert.ok(huge.length > 64 * 1024, 'test body must exceed the 64 KiB cap');
const lockPath = writeLock(dir, huge, 2 * 60 * 1000); // stale, under the deadman
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'an oversized lock body must be treated as unverifiable (no pid) → NOT stolen under the deadman');
assert.ok(fs.existsSync(lockPath), 'the oversized lock must remain in place (not read/stolen)');
});
// Revert-fails: drop the startTime field from the lock body written by acquireLock → the body has no
// `startTime` key, so this assertion (startTime present + equals the cached self start-time when
// obtainable, else null) fails on the missing key.
test('finding-1: acquireLock records hostname + pid + token + startTime in the lockfile body', () => {
const dir = runtime();
const handle = lifecycle.acquireLock(dir);
assert.ok(handle, 'acquireLock must succeed on a fresh dir');
const raw = fs.readFileSync(handle.path, 'utf8');
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(raw); }, 'lock body must be JSON');
assert.strictEqual(parsed.hostname, os.hostname(), 'lock body must record the hostname');
assert.strictEqual(parsed.pid, process.pid, 'lock body must record the pid');
assert.strictEqual(typeof parsed.token, 'string', 'lock body must record the owner token');
assert.strictEqual(parsed.token, handle.token, 'the recorded token must equal the handle token');
// startTime must be PRESENT as a key (string when obtainable on this OS, null otherwise) — and must
// equal what getProcessStartTime reports for THIS process (the cached self start-time).
assert.ok('startTime' in parsed, 'lock body must record a startTime key');
const selfStart = lifecycle.getProcessStartTime(process.pid);
assert.strictEqual(parsed.startTime, selfStart === null ? null : selfStart,
'recorded startTime must equal this process\'s observed start-time');
lifecycle.releaseLock(handle);
});
// ---------------------------------------------------------------------------
// Finding 1 (HIGH): lock-steal TOCTOU — stale `mtime` age applied to a REPLACEMENT
// lock body. A acquirer must bind its age decision to the SAME body instance it acts
// on: for a JSON body the age comes from the body's own `ts` field (now - body.ts),
// NOT the file `mtime` (a fresh replacement body carries a fresh `ts` → small age →
// not stolen). And immediately BEFORE the atomic rename-steal it must re-stat and
// confirm dev/ino (and, for JSON, body `ts`) are UNCHANGED; if changed → do NOT
// steal, retry the bounded loop (B's fresh lock must not be rename-stolen).
// ---------------------------------------------------------------------------
// Revert-fails: derive age from `mtime` instead of the body `ts` → the body carries a RECENT ts but
// the file mtime is backdated 2 min, so a mtime-age would read it as STALE and (pid dead) STEAL it,
// making handle non-null. With ts-bound age the lock is FRESH → NOT stolen, so this strictEqual(null)
// holds only when age is bound to the body instance.
test('finding-1: a JSON lock with a RECENT body `ts` but an artificially-OLD mtime is NOT stolen (age binds to the body, not mtime)', (t) => {
const dir = runtime();
// pid "dead" so a mtime-derived STALE age would steal it; only ts-bound freshness can protect it.
withLockProbes(t, { alive: false, startTime: 'START-A' });
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Body `ts` is NOW (fresh replacement); file mtime is backdated 2 minutes (would look stale).
const freshBody = JSON.stringify({ token: `${process.pid}-${Date.now()}-1`, pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now() });
fs.writeFileSync(lockPath, freshBody, 'utf8');
const old = new Date(Date.now() - 2 * 60 * 1000);
fs.utimesSync(lockPath, old, old);
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'a JSON lock whose BODY ts is fresh must be treated as FRESH (not stolen) even with an old mtime');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), freshBody, 'the fresh-ts lock body must be untouched');
});
// A legacy/no-`ts` body still uses the mtime age (fallback). Revert-fails: if the fallback to mtime
// for a no-ts body is dropped (e.g. treat missing ts as age 0 = fresh), a stale dead-pid legacy lock
// past the deadman would never be stolen and this "must be stolen" assertion fails.
test('finding-1: a legacy/no-`ts` body falls back to mtime age (stale past deadman → stolen)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
writeLock(dir, 'legacy-token-no-pid', 11 * 60 * 1000); // no ts → mtime age → past deadman
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token, 'a no-ts legacy lock past the deadman (mtime age) must be stolen');
lifecycle.releaseLock(handle);
});
// Revert-fails: drop the pre-rename dev/ino recheck → when B replaces the lock inode between A's
// steal-decision and A's rename, A rename-steals B's FRESH lock (concurrent mutation). With the
// recheck, the changed inode makes A `continue` (no steal), so the on-disk lock is never renamed —
// this test forces a DIFFERENT ino on every recheck stat and asserts A never steals (returns null,
// body untouched).
test('finding-1: an inode change between the steal-decision and the rename causes a RETRY, not a steal', (t) => {
const dir = runtime();
const { mock } = require('node:test');
withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → otherwise steal-eligible
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Stale legacy body (no ts → mtime age), backdated past the deadman so the steal branch is reached.
const body = 'legacy-token-no-pid';
fs.writeFileSync(lockPath, body, 'utf8');
const old = new Date(Date.now() - 11 * 60 * 1000);
fs.utimesSync(lockPath, old, old);
// openSync('wx') always reports the lock held; renameSync would (without the recheck) "succeed".
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'); err.code = 'EEXIST'; throw err;
}
return realOpen(p, flags, ...rest);
});
// Alternate the reported inode: the DECISION stat sees ino=1, the pre-rename RECHECK stat sees
// ino=2 (B swapped the inode). Every recheck therefore observes a changed inode → A must retry.
let statCalls = 0;
const realStat = fs.statSync.bind(fs);
const statMock = mock.method(fs, 'statSync', function (p, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock')) {
statCalls += 1;
const ino = (statCalls % 2 === 1) ? 1 : 2; // decision: 1, recheck: 2 (changed)
return { mtimeMs: Date.now() - 11 * 60 * 1000, dev: 1, ino, size: body.length, isFile: () => true };
}
return realStat(p, ...rest);
});
// If the recheck were absent, rename would fire and steal; track whether it was ever called on .lock.
let renamedLock = false;
const renameMock = mock.method(fs, 'renameSync', function (from, ...rest) {
if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; }
return require('node:fs').renameSync.wrappedMethod
? require('node:fs').renameSync.wrappedMethod(from, ...rest)
: undefined;
});
t.after(() => { openMock.mock.restore(); statMock.mock.restore(); renameMock.mock.restore(); });
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'A must NOT acquire (every recheck saw a changed inode → retry, never steal)');
assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose inode changed under it');
assert.ok(statCalls >= 2, 'both a decision-stat and a pre-rename recheck-stat must have run');
});
// Revert-fails: drop the pre-rename body `ts` recheck for JSON bodies → when B replaces the JSON body
// with a fresh `ts` (same inode) between A's decision and rename, A still steals. With the ts recheck,
// the changed ts makes A `continue`. Here the DECISION read sees an OLD ts (steal-eligible) but the
// RECHECK read sees a NEW ts → A must NOT steal.
test('finding-1: a body `ts` change between the steal-decision and the rename causes a RETRY, not a steal', (t) => {
const dir = runtime();
const { mock } = require('node:test');
withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → steal-eligible if stale
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// DECISION body: OLD ts (stale → steal-eligible with a dead pid). RECHECK body: fresh ts (B's swap).
const oldTs = Date.now() - 2 * 60 * 1000;
const oldBody = JSON.stringify({ token: 't-old', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: oldTs });
const newBody = JSON.stringify({ token: 't-new', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now() });
fs.writeFileSync(lockPath, oldBody, 'utf8');
// Track which fds belong to the .lock so fstat/readSync can be steered for them only. A 'wx' create
// throws EEXIST (held); an O_RDONLY|O_NONBLOCK read open returns the real fd and is registered.
const lockFds = new Set();
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'); err.code = 'EEXIST'; throw err;
}
const fd = realOpen(p, flags, ...rest);
if (typeof p === 'string' && p.endsWith('.lock')) lockFds.add(fd);
return fd;
});
// Same dev/ino across stats (so the body TS — not the inode — is the discriminator under test).
const realStat = fs.statSync.bind(fs);
const statMock = mock.method(fs, 'statSync', function (p, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock')) {
return { mtimeMs: oldTs, dev: 7, ino: 7, size: oldBody.length, isFile: () => true };
}
return realStat(p, ...rest);
});
// The body is read via the fd-based reader (fstatSync + readSync). The DECISION read (the 1st
// readSmallRegularFile of the .lock) returns oldBody; every later RECHECK read returns newBody.
// Count fstatSync calls on .lock fds — one per readSmallRegularFile — to alternate the body.
const realFstat = fs.fstatSync.bind(fs);
let bodyReads = 0;
let activeBody = oldBody;
const fstatMock = mock.method(fs, 'fstatSync', function (fd, ...rest) {
if (lockFds.has(fd)) {
bodyReads += 1;
activeBody = bodyReads === 1 ? oldBody : newBody; // decision: old, recheck(s): new
return { isFile: () => true, isDirectory: () => false, size: activeBody.length };
}
return realFstat(fd, ...rest);
});
const realReadSync = fs.readSync.bind(fs);
const readMock = mock.method(fs, 'readSync', function (fd, buffer, offset, length, position, ...rest) {
if (lockFds.has(fd)) {
const bytes = Buffer.from(activeBody, 'utf8');
const n = Math.min(length, bytes.length - (position || 0));
if (n <= 0) return 0;
bytes.copy(buffer, offset, position || 0, (position || 0) + n);
return n;
}
return realReadSync(fd, buffer, offset, length, position, ...rest);
});
let renamedLock = false;
const renameMock = mock.method(fs, 'renameSync', function (from) {
if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; }
return undefined;
});
t.after(() => { openMock.mock.restore(); statMock.mock.restore(); fstatMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); });
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'A must NOT steal a JSON lock whose body ts changed (B replaced it) before the rename');
assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose body ts changed under it');
assert.ok(bodyReads >= 2, 'both a decision body-read and a pre-rename recheck body-read must have run');
});
// Finding 3 (LOW): sameLockInstance must reject a DISAPPEARING ts, not just a ts mismatch. If the
// DECISION body had a non-null JSON ts but the RECHECK body (same inode) is now no-ts/garbage, the
// "ts re-confirmed before steal" invariant is broken — the body changed under us, so A must retry,
// NOT steal. (The prior code only rejected when BOTH ts were non-null and differed; a null recheck ts
// slipped through as "same".)
// Revert-fails: keep the old `a.ts !== null && b.ts !== null && a.ts !== b.ts` guard → a decision ts
// that goes null on recheck is treated as the SAME instance, so A rename-steals it; this
// strictEqual(null)/renamedLock===false pair fails.
test('finding-3: a body `ts` going NULL between the steal-decision and the rename causes a RETRY, not a steal', (t) => {
const dir = runtime();
const { mock } = require('node:test');
withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → steal-eligible if stale
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// DECISION body: a JSON body with a non-null OLD ts (stale → steal-eligible with a dead pid).
// RECHECK body: a no-`ts` legacy/garbage body on the SAME inode (B replaced the body content).
const oldTs = Date.now() - 2 * 60 * 1000;
const oldBody = JSON.stringify({ token: 't-old', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: oldTs });
const recheckBody = 'legacy-no-ts-garbage';
fs.writeFileSync(lockPath, oldBody, 'utf8');
const lockFds = new Set();
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'); err.code = 'EEXIST'; throw err;
}
const fd = realOpen(p, flags, ...rest);
if (typeof p === 'string' && p.endsWith('.lock')) lockFds.add(fd);
return fd;
});
// Same dev/ino across stats (so the body ts disappearing — not the inode — is the discriminator).
const realStat = fs.statSync.bind(fs);
const statMock = mock.method(fs, 'statSync', function (p, ...rest) {
if (typeof p === 'string' && p.endsWith('.lock')) {
return { mtimeMs: oldTs, dev: 9, ino: 9, size: oldBody.length, isFile: () => true };
}
return realStat(p, ...rest);
});
// fd-based reader (fstatSync + readSync): 1st .lock body read = oldBody (has ts); later reads =
// recheckBody (no ts). Alternate on the fstatSync call count (one per readSmallRegularFile).
const realFstat = fs.fstatSync.bind(fs);
let bodyReads = 0;
let activeBody = oldBody;
const fstatMock = mock.method(fs, 'fstatSync', function (fd, ...rest) {
if (lockFds.has(fd)) {
bodyReads += 1;
activeBody = bodyReads === 1 ? oldBody : recheckBody; // decision: has-ts, recheck(s): no-ts
return { isFile: () => true, isDirectory: () => false, size: activeBody.length };
}
return realFstat(fd, ...rest);
});
const realReadSync = fs.readSync.bind(fs);
const readMock = mock.method(fs, 'readSync', function (fd, buffer, offset, length, position, ...rest) {
if (lockFds.has(fd)) {
const bytes = Buffer.from(activeBody, 'utf8');
const n = Math.min(length, bytes.length - (position || 0));
if (n <= 0) return 0;
bytes.copy(buffer, offset, position || 0, (position || 0) + n);
return n;
}
return realReadSync(fd, buffer, offset, length, position, ...rest);
});
let renamedLock = false;
const renameMock = mock.method(fs, 'renameSync', function (from) {
if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; }
return undefined;
});
t.after(() => { openMock.mock.restore(); statMock.mock.restore(); fstatMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); });
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'A must NOT steal a lock whose decision ts went NULL on recheck (body changed)');
assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose body ts disappeared under it');
assert.ok(bodyReads >= 2, 'both a decision body-read and a pre-rename recheck body-read must have run');
});
// ---------------------------------------------------------------------------
// Finding 2 (HIGH): the lock body is untrusted — its bounded read must go through the
// shared fd-based regular-file reader (reject FIFO/device/non-regular, cap size) so a
// FIFO/device/symlink lock cannot block or read unbounded. A non-regular lock body is
// treated as unparseable (no pid/host) → deadman policy (not stolen under the deadman).
// ---------------------------------------------------------------------------
// Revert-fails: read the lock body via statSync(path)+readFileSync(path) → a FIFO lock body blocks
// acquireLock forever (no writer). With the fd-based regular-file reader the FIFO body is rejected as
// non-regular → unparseable (no pid) → NOT stolen under the deadman, so acquireLock returns promptly.
test('finding-2: a FIFO lock body does NOT hang acquireLock — treated as unparseable (deadman policy)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
if (!tryMkfifoLife(lockPath)) { t.skip('mkfifo unavailable on this platform'); return; }
const old = new Date(Date.now() - 2 * 60 * 1000); // stale, under the deadman
try { fs.utimesSync(lockPath, old, old); } catch { /* FIFO utimes best-effort */ }
let handle;
assert.doesNotThrow(() => { handle = lifecycle.acquireLock(dir); },
'acquireLock must not hang/throw on a FIFO lock body');
assert.strictEqual(handle, null,
'a FIFO (non-regular) lock body is unparseable (no pid) → NOT stolen under the deadman');
});
// ---------------------------------------------------------------------------
// Finding 3 (LOW): partial lock orphan. After openSync(lockPath,'wx') succeeds, a
// body-write/closeSync failure must unlink the just-created (empty) .lock so it does
// not self-block until the deadman. Revert-fails: drop the cleanup-unlink → the
// orphan .lock remains and this "no .lock left behind" assertion fails.
//
// Finding 2 (MEDIUM): the lock body is written with fs.writeFileSync(fd, …) (full-buffer write, no
// short-writes), NOT a bare fs.writeSync(fd, …). Mocking fs.writeFileSync to fail proves the body
// write goes through writeFileSync — if the code regressed to a bare writeSync this mock would NOT
// fire, the write would succeed, and acquireLock would return a handle (this strictEqual(null) fails).
// ---------------------------------------------------------------------------
test('finding-3/2: a body-write (writeFileSync) failure after the exclusive create leaves NO orphan .lock behind', (t) => {
const dir = runtime();
const { mock } = require('node:test');
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Let the exclusive create succeed (real openSync), then force the body write to fail. The body MUST
// be written via writeFileSync (finding 2), so mocking writeFileSync to throw is what trips it; if
// the code used a bare writeSync this would never fire (proving writeFileSync is the write path).
let writeFileFired = false;
const writeFileMock = mock.method(fs, 'writeFileSync', function (fd) {
// Only fail the fd-targeted lock body write (a numeric fd), not any path-based writes.
if (typeof fd === 'number') {
writeFileFired = true;
const err = new Error('ENOSPC: no space left on device'); err.code = 'ENOSPC'; throw err;
}
return undefined;
});
t.after(() => { writeFileMock.mock.restore(); });
const handle = lifecycle.acquireLock(dir);
assert.ok(writeFileFired, 'the lock body must be written via fs.writeFileSync(fd, …) (finding 2 short-write fix)');
assert.strictEqual(handle, null, 'acquireLock must return null when the lock body write fails');
assert.ok(!fs.existsSync(lockPath),
'the empty .lock created before the failed write must be unlinked (no self-blocking orphan)');
});
// Finding 5(c): the closeSync-failure variant. After openSync(lockPath,'wx') succeeds and the body
// write succeeds, a closeSync failure must ALSO unlink the just-created .lock (the body may be
// unflushed/partial) so no self-blocking orphan remains until the deadman.
// Revert-fails: drop the closeSync-error cleanup-unlink in acquireLock → the .lock written before the
// failed close is left behind and this "no .lock left behind" assertion fails.
test('finding-3: a closeSync failure after the exclusive create+write leaves NO orphan .lock behind', (t) => {
const dir = runtime();
const { mock } = require('node:test');
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Let the exclusive create AND the body write succeed; force ONLY the .lock fd's closeSync to fail.
// Track which fds belong to the .lock so unrelated closeSync calls (dir fsync, etc.) are untouched.
const realOpen = fs.openSync.bind(fs);
const lockFds = new Set();
const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) {
const fd = realOpen(p, flags, ...rest);
if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') lockFds.add(fd);
return fd;
});
const realClose = fs.closeSync.bind(fs);
const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) {
if (lockFds.has(fd)) {
lockFds.delete(fd);
const err = new Error('EIO: delayed-writeback failure on close'); err.code = 'EIO'; throw err;
}
return realClose(fd, ...rest);
});
t.after(() => { openMock.mock.restore(); closeMock.mock.restore(); });
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null, 'acquireLock must return null when the lock fd close fails');
assert.ok(!fs.existsSync(lockPath),
'the .lock created before the failed close must be unlinked (no self-blocking orphan)');
});
// ---------------------------------------------------------------------------
// Finding 1 (HIGH): a FUTURE / implausibly-far-future body `ts` must NOT deadlock the
// lock forever. age = now - ts goes negative (or stays tiny) for a future ts, keeping
// age <= LOCK_STALE_MS so the lock is NEVER stale/deadman/steal-eligible → permanent
// block. The fix distrusts a future ts and falls back to the file `mtime` for the age,
// so stale/deadman recovery still bounds the lock.
// ---------------------------------------------------------------------------
// Revert-fails: drop the future-ts guard (use `now - ts` even when ts is in the future) → the
// far-future body ts makes age negative (<= LOCK_STALE_MS) forever, so the lock is never stolen and
// acquireLock returns null — this "must be stolen" assertion fails (the permanent deadlock).
test('finding-1: a lock whose JSON body `ts` is far in the FUTURE is still reclaimed (mtime fallback), not blocked forever', (t) => {
const dir = runtime();
// Different host + past the deadman by MTIME so the deadman branch reclaims it once the future ts is
// distrusted. (A future ts under the trusting code would compute a negative age → never stale.)
withLockProbes(t, { alive: true, startTime: 'START-A' });
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// Body ts is 1 hour in the FUTURE; file mtime is backdated 11 min (past the deadman).
const futureBody = JSON.stringify({
token: 't-future', pid: process.pid, hostname: os.hostname() + '-OTHER-HOST',
startTime: 'START-A', ts: Date.now() + 60 * 60 * 1000,
});
fs.writeFileSync(lockPath, futureBody, 'utf8');
const old = new Date(Date.now() - 11 * 60 * 1000);
fs.utimesSync(lockPath, old, old);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a lock with a far-FUTURE body ts must distrust the ts and fall back to mtime age → reclaimed past the deadman');
lifecycle.releaseLock(handle);
});
// ---------------------------------------------------------------------------
// Finding 4 (LOW): releaseLock check-then-unlink TOCTOU minimization. The original
// holder must rmSync ONLY when its OWN owner token is still in the lock body. A
// SUCCESSOR lock (a new acquirer's lock at the same path, with a DIFFERENT token)
// must NOT be deleted by the original holder's stale release.
//
// The PRIMARY, portable discriminator is the TOKEN re-check: a real successor wrote a
// different token, so releaseLock reads a non-matching token and refuses to delete.
// (The dev/ino recheck in releaseLock is a best-effort SECONDARY guard that may be
// defeated by inode reuse on some filesystems — e.g. Linux ext4/overlay reusing the
// freed inode after unlink+recreate — so this test does NOT rely on it: it asserts the
// token mechanism, which holds on macOS AND Linux.)
// ---------------------------------------------------------------------------
// Revert-fails: drop the token re-check in releaseLock (delete on inode-match-only, or unconditionally)
// → the original holder rmSyncs the successor's lock at the same path even though the body now carries a
// DIFFERENT token, so the successor .lock is deleted and this "successor survives" assertion fails. The
// token re-check (body token != handle.token) is what prevents the delete.
test('finding-4: releaseLock does NOT delete a SUCCESSOR lock (different token) at the same path', () => {
const dir = runtime();
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
// The original holder acquires (captures its token + dev/ino in the handle).
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.path === lockPath, 'original holder must acquire the lock');
// Simulate the lock being stale-stolen then re-created by a SUCCESSOR at the same path with a
// DIFFERENT token. We do NOT mock the body read — releaseLock reads the REAL successor token below.
// (We deliberately do not assert anything about the inode: Linux may reuse the freed inode after
// unlink+recreate, so inode-distinctness is non-portable and is NOT the mechanism under test.)
fs.unlinkSync(lockPath);
const successorBody = JSON.stringify({
token: 'successor-token', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now(),
});
fs.writeFileSync(lockPath, successorBody, 'utf8');
assert.notStrictEqual(handle.token, 'successor-token', 'the successor token must differ from the original holder token');
lifecycle.releaseLock(handle);
assert.ok(fs.existsSync(lockPath), 'the SUCCESSOR lock at the same path must NOT be deleted by the original holder');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), successorBody,
'the successor lock body must be untouched (original holder read a non-matching token and did not rmSync it)');
});
// Sanity companion: the NORMAL case (same inode + our token) still releases (so the inode guard does
// not break legitimate release). Revert this would not be a fix-revert; it pins that the guard is not
// over-strict (the dev/ino captured at acquire matches the unchanged on-disk lock → rmSync runs).
test('finding-4: releaseLock STILL deletes our own unchanged lock (inode guard is not over-strict)', () => {
const dir = runtime();
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
const handle = lifecycle.acquireLock(dir);
assert.ok(handle, 'must acquire');
assert.ok(fs.existsSync(lockPath), 'lock present before release');
lifecycle.releaseLock(handle);
assert.ok(!fs.existsSync(lockPath), 'our own unchanged lock (matching token + inode) must be released');
});
// ---------------------------------------------------------------------------
// CONC-2 / DOS-1 (LOW): acquireLock must be a BOUNDED iterative loop, not
// unbounded recursion. A pathological never-acquirable lock must return null
// without a stack overflow.
// Revert-fails: restore the recursive `return acquireLock(runtimeDir)` calls →
// the forced infinite contention recurses until RangeError (stack overflow),
// so this test throws instead of returning null within the attempt cap.
// ---------------------------------------------------------------------------
test('CONC-2: acquireLock returns null on contention exhaustion WITHOUT a stack overflow (bounded loop)', (t) => {
const dir = runtime();
const { mock } = require('node:test');
const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
// 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;
}
return realOpen(p, flags, ...rest);
});
// 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: staleMtime, size: 256, dev: 1, ino: 1, isFile: () => true, isDirectory: () => false };
}
return realStat(p, ...rest);
});
// 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(
() => { handle = lifecycle.acquireLock(dir); },
'acquireLock must not throw (no stack overflow) under pathological contention',
);
assert.strictEqual(handle, null, 'acquireLock must return null on attempt exhaustion');
});
// ---------------------------------------------------------------------------
// MEDIUM finding — future mtime can deadlock lock recovery.
//
// `lockAgeMs` already distrusts a future body `ts` and falls back to `mtime`.
// But if `mtime` is ALSO in the future (planted lock, or system clock stepped
// backward after the lock was written), `Date.now() - mtimeMs` is negative →
// `age <= LOCK_STALE_MS` stays true → the lock is treated as "fresh" forever →
// permanent block of all capability mutations.
//
// Fix: when the mtime fallback also yields a negative age (untrustworthy source),
// return `Number.MAX_SAFE_INTEGER` so the lock routes into the normal steal
// decision tree. The verified-live guard (same-host + pid alive + start-time
// match) is age-independent and still prevents false steals.
// ---------------------------------------------------------------------------
// Revert-fails: revert the `Number.MAX_SAFE_INTEGER` clamp in lockAgeMs (leave the
// raw `Date.now() - mtimeMs` when it is negative) → age stays negative →
// `age <= LOCK_STALE_MS` is always true → acquireLock returns null instead of a
// handle, so this `assert.ok(handle && handle.token)` fails.
test('MEDIUM: future mtime + DEAD pid → lock IS stolen (negative mtime age must not deadlock recovery)', (t) => {
const dir = runtime();
// Probe: pid is dead AND no start-time → definitely not verified-live.
withLockProbes(t, { alive: false, startTime: 'START-A' });
// Write a lock with FUTURE body ts AND future mtime (negative ageMs = future).
// writeLock(dir, body, ageMs) sets both `obj.ts = Date.now() - ageMs` and
// `utimesSync` to `Date.now() - ageMs`; negative ageMs pushes both into the future.
const FUTURE_MS = -5 * 60 * 1000; // 5 minutes in the future
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a future-mtime lock with a dead pid must be stolen (negative age must clamp to MAX_SAFE_INTEGER, not block forever)');
lifecycle.releaseLock(handle);
});
// Revert-fails: revert the clamp → age stays negative → acquireLock returns null
// (permanent block), so this `ok(handle)` fails.
test('MEDIUM: future body ts + future mtime + dead/unverifiable pid → lock IS stolen (both sources untrustworthy)', (t) => {
const dir = runtime();
withLockProbes(t, { alive: true, startTime: null }); // alive but start-time unobservable → unverifiable
const FUTURE_MS = -10 * 60 * 1000; // 10 minutes in the future
writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS);
const handle = lifecycle.acquireLock(dir);
assert.ok(handle && handle.token,
'a lock with both future body-ts and future mtime, held by an unverifiable pid, must be stolen');
lifecycle.releaseLock(handle);
});
// Revert-fails: NOT a fix-revert; pins that the clamp does NOT break the verified-live
// protection. A future-mtime lock whose holder is SAME-host + pid alive + start-time
// MATCH must NOT be stolen even after the age clamp kicks in (clamping to MAX_SAFE_INTEGER
// makes it steal-eligible by age, but the verified-live check is age-independent and still
// blocks the steal). If the clamp incorrectly bypasses verified-live, acquireLock returns a
// handle here instead of null, and the `strictEqual(null)` fails.
test('MEDIUM: future mtime + VERIFIED-LIVE same-host holder → lock is NOT stolen (clamp does not break liveness guard)', (t) => {
const dir = runtime();
// Probe: pid alive AND observed start-time MATCHES the recorded one → verified-live.
withLockProbes(t, { alive: true, startTime: 'START-A' });
const FUTURE_MS = -5 * 60 * 1000;
const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS);
const original = fs.readFileSync(lockPath, 'utf8');
const handle = lifecycle.acquireLock(dir);
assert.strictEqual(handle, null,
'a future-mtime lock held by a verified-live same-host process must NOT be stolen');
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched');
});
// ---------------------------------------------------------------------------
// CONC-3 (LOW): backup names must carry a crypto nonce so two processes upgrading
// at the same millisecond cannot collide.
// Revert-fails: drop the randomBytes nonce from the upgrade backupName → with
// Date.now and pid stubbed equal the second upgrade's backupName equals the
// first's, so the captured backup names are identical and the inequality fails.
// ---------------------------------------------------------------------------
test('CONC-3: upgrade backupName includes a random nonce (collision-resistant across same-ms processes)', async (t) => {
const dir = runtime();
await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/a.js' })),
});
// Capture the backupName the upgrade records into the _pending intent by spying on
// ledgerMod.recordInstall and reading the _pending.backupName.
const { mock } = require('node:test');
const capturedBackupNames = [];
const realRecord = ledgerMod.recordInstall.bind(ledgerMod);
const recordMock = mock.method(ledgerMod, 'recordInstall', function (rd, entry) {
if (entry && entry._pending && typeof entry._pending.backupName === 'string') {
capturedBackupNames.push(entry._pending.backupName);
}
return realRecord(rd, entry);
});
// Freeze Date.now so the timestamp portion is identical between two upgrades — only the
// nonce can differ.
const realNow = Date.now;
Date.now = () => 1700000000000;
t.after(() => { recordMock.mock.restore(); Date.now = realNow; });
await lifecycle.upgradeCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(execCap('e', '2.0.0', { script: 'hooks/a.js' })),
});
await lifecycle.upgradeCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(execCap('e', '3.0.0', { script: 'hooks/a.js' })),
});
assert.ok(capturedBackupNames.length >= 2,
`must capture at least two upgrade backupNames; got: ${JSON.stringify(capturedBackupNames)}`);
assert.notStrictEqual(capturedBackupNames[0], capturedBackupNames[1],
`same-ms upgrade backup names must differ via the random nonce; got both: ${capturedBackupNames[0]}`);
});
// ---------------------------------------------------------------------------
// DUR-3 (HIGH): promoteStagingToFinal must fsync the parent directory after BOTH
// renames (old→backup AND staging→final) so a crash between them cannot lose the
// backup → reconcile silent-uninstall. The upgrade/reinstall path does exactly
// two renames, so it must produce exactly TWO parent-dir fsyncs.
// Revert-fails: drop EITHER fsyncDir(parent) in promoteStagingToFinal → the count
// drops to 1 (or 0) and the `=== 2` assertion fails (a one-fsync regression that the
// prior `>= 1` assertion would have silently passed).
//
// Note: the fd-tracking set REMOVES fds on close, because once a capsRoot dir fd is
// closed the OS may reuse the SAME fd number for an unrelated open (e.g. the ledger
// write's containing-dir fsync of runtimeDir), which must NOT be miscounted. Without
// close-tracking the count is noisy (observed 4) and would mask a regression to 2/3.
// ---------------------------------------------------------------------------
test('DUR-3: promoteStagingToFinal fsyncs the parent directory after BOTH renames (exactly two, durable backup swap)', async (t) => {
const dir = runtime();
// First install so a prior bundle exists (so the upgrade path renames old→backup).
await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('e', '1.0.0')),
});
const capsRoot = path.join(dir, '.gsd', 'capabilities');
const { mock } = require('node:test');
const realOpen = fs.openSync.bind(fs);
const realFsync = fs.fsyncSync.bind(fs);
const realClose = fs.closeSync.bind(fs);
const dirFds = new Set();
let parentDirFsyncs = 0;
const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) {
const fd = realOpen(p, flags, ...rest);
if (typeof p === 'string' && path.resolve(p) === path.resolve(capsRoot) && flags === 'r') dirFds.add(fd);
return fd;
});
const fsyncMock = mock.method(fs, 'fsyncSync', function (fd, ...rest) {
if (dirFds.has(fd)) parentDirFsyncs++;
return realFsync(fd, ...rest);
});
const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) {
// Drop the fd from the tracked set BEFORE closing: a reused fd number for a later non-capsRoot
// open must not be counted as a capsRoot dir fsync.
if (dirFds.has(fd)) dirFds.delete(fd);
return realClose(fd, ...rest);
});
t.after(() => { openMock.mock.restore(); fsyncMock.mock.restore(); closeMock.mock.restore(); });
// Reinstall over the existing bundle (upgrade-like path → old→backup, staging→final = two renames).
const res = await lifecycle.installCapability('./e', {
runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true,
_resolve: fakeResolve(declarativeCap('e', '2.0.0')),
});
assert.strictEqual(res.status, 'installed');
assert.strictEqual(parentDirFsyncs, 2,
`promoteStagingToFinal must fsync the parent dir after BOTH renames (exactly 2); fsync count=${parentDirFsyncs}`);
});
// ---------------------------------------------------------------------------
// Finding 4 (MEDIUM): the directory fsync in the lifecycle (fsyncDir, used by
// promoteStagingToFinal) must NOT swallow ALL errors. It tolerates ONLY
// EISDIR/EPERM/EINVAL/EBADF; any other errno (e.g. EIO) RETHROWS (durability
// could not be confirmed). The dir fd must still be closed.
// ---------------------------------------------------------------------------
/** Mock fs.fsyncSync to throw `errno` ONLY for the capabilities-root dir fd (opened 'r'). */
function withLifecycleDirFsyncError(t, capsRoot, errno) {
const dirFds = new Set();
const closed = [];
const realOpen = fs.openSync.bind(fs);
const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) {
const fd = realOpen(p, flags, ...rest);
if (typeof p === 'string' && path.resolve(p) === path.resolve(capsRoot) && flags === 'r') dirFds.add(fd);
return fd;
});
const realClose = fs.closeSync.bind(fs);
const closeMock = mock.method(fs, 'closeSync', function (fd) {
// Remove the fd from the tracked set BEFORE closing: once closed the OS may reuse the same
// fd NUMBER for an unrelated open (e.g. writeLedger's temp file), which must NOT be treated
// as the capabilities-root dir fd.
if (dirFds.has(fd)) { closed.push(fd); dirFds.delete(fd); }
return realClose(fd);
});
const realFsync = fs.fsyncSync.bind(fs);
const fsyncMock = mock.method(fs, 'fsyncSync', function (fd) {
if (dirFds.has(fd)) { const e = new Error(`${errno}: injected`); e.code = errno; throw e; }
return realFsync(fd);
});
t.after(() => { openMock.mock.restore(); closeMock.mock.restore(); fsyncMock.mock.restore(); });
return { closed };
}
// Revert-fails: restore the swallow-all `catch {}` in fsyncDir → the EIO dir-fsync
// during promotion is swallowed, the install COMMITS and returns 'installed', so
// this "must be blocked" assertion fails.
test('finding-4: a NON-tolerated dir-fsync errno (EIO) during install promotion surfaces as blocked (durability not silently claimed)', async (t) => {
const dir = runtime();
const capsRoot = path.join(dir, '.gsd', 'capabilities');
fs.mkdirSync(capsRoot, { recursive: true });
const { closed } = withLifecycleDirFsyncError(t, capsRoot, 'EIO');
const res = await lifecycle.installCapability('./d', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('d', '1.0.0')),
});
assert.strictEqual(res.status, 'blocked',
'an EIO directory-fsync error during promotion must NOT be swallowed (install blocked)');
assert.ok((res.blockReasons || []).some((r) => /durab/i.test(r)),
`block reason must indicate durability could not be confirmed; got ${JSON.stringify(res.blockReasons)}`);
assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)');
});
// Revert-fails: remove the tolerated-errno allowlist (rethrow EVERYTHING) → EISDIR
// would block the install, so this 'installed' assertion fails.
test('finding-4: a TOLERATED dir-fsync errno (EISDIR) during install promotion is ignored (install succeeds)', async (t) => {
const dir = runtime();
const capsRoot = path.join(dir, '.gsd', 'capabilities');
fs.mkdirSync(capsRoot, { recursive: true });
const { closed } = withLifecycleDirFsyncError(t, capsRoot, 'EISDIR');
const res = await lifecycle.installCapability('./d', {
runtimeDir: dir, hostVersion: '1.6.0',
_resolve: fakeResolve(declarativeCap('d', '1.0.0')),
});
assert.strictEqual(res.status, 'installed',
'an EISDIR directory-fsync error must be tolerated (best-effort) — install succeeds');
assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)');
});
// ---------------------------------------------------------------------------
// DUR-6 (LOW): reconcile upgrade-rollback must renameSync(backup→final) FIRST
// (atomic replace), not rmSync(final) before the rename — a crash between the
// two leaves both gone.
// Revert-fails: restore `rmSync(finalDir)` BEFORE `renameSync(backupDir, finalDir)`
// → if we make ONLY the post-rmSync rename fail, the old rmSync-first order has
// already destroyed finalDir, so the bundle is lost; the new order renames first
// (no rmSync of finalDir) so the bundle survives. This test injects a crash right
// after a (hypothetical) rmSync of finalDir and asserts the final bundle survives.
// ---------------------------------------------------------------------------
test('DUR-6: reconcile upgrade-rollback renames backup→final atomically (no rmSync-before-rename data loss)', (t) => {
const dir = runtime();
// Seed an in-flight upgrade: a NEW (uncommitted) bundle live + the OLD backup set aside,
// with a pending upgrade intent naming the backup.
const backupName = 'c.upgrading-111-222';
seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new/uncommitted live dir
seedCapDir(dir, backupName, declarativeCap('c', '1.0.0')); // old backup to restore
recordPending(dir, 'c', '2.0.0', { kind: 'upgrade', backupName, sharedFiles: [] });
// Spy: assert reconcile NEVER rmSyncs the finalDir before the backup rename. If the buggy
// order is restored, finalDir is rmSync'd first; the new order must rename the backup over
// finalDir directly (renameSync replaces atomically), so no rmSync of finalDir occurs.
const { mock } = require('node:test');
const finalDir = path.join(dir, '.gsd', 'capabilities', 'c');
const realRm = fs.rmSync.bind(fs);
const realRename = fs.renameSync.bind(fs); // capture BEFORE mocking
let finalDirRmBeforeRename = false;
let backupRenamedToFinal = false;
const renameMock = mock.method(fs, 'renameSync', function (src, dst, ...rest) {
if (typeof src === 'string' && typeof dst === 'string'
&& path.resolve(src) === path.resolve(path.join(dir, '.gsd', 'capabilities', backupName))
&& path.resolve(dst) === path.resolve(finalDir)) {
backupRenamedToFinal = true;
}
return realRename(src, dst, ...rest);
});
const rmMock = mock.method(fs, 'rmSync', function (p, ...rest) {
if (typeof p === 'string' && path.resolve(p) === path.resolve(finalDir) && !backupRenamedToFinal) {
finalDirRmBeforeRename = true;
}
return realRm(p, ...rest);
});
t.after(() => { renameMock.mock.restore(); rmMock.mock.restore(); });
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
assert.ok(report.rolledBack.includes('c'), 'upgrade rollback must roll back c');
assert.strictEqual(finalDirRmBeforeRename, false,
'reconcile must NOT rmSync(finalDir) before renaming the backup over it (DUR-6 ordering)');
assert.ok(backupRenamedToFinal, 'reconcile must renameSync(backup→final) to restore the old bundle');
// The restored bundle is the OLD version.
assert.strictEqual(capManifestVersion(dir, 'c'), '1.0.0', 'rolled-back bundle must be the old version');
});
// ---------------------------------------------------------------------------
// DOS-2 (MED): reconcile step-1 must accumulate mutations and write the ledger
// ONCE at the end of step 1, not once per pending entry.
// Revert-fails: restore per-entry recordInstall/removeEntry/writeLedger calls in
// step 1 → with N pending entries the ledger is written N times, so the spied
// writeLedger call count exceeds 1 for step-1 mutations and the "<= a small
// bound" assertion fails.
// ---------------------------------------------------------------------------
test('DOS-2: reconcile with N pending entries writes the ledger at most once for step-1 mutations', (t) => {
const dir = runtime();
// Seed several uncommitted FRESH installs (kind 'install') — each would, in the buggy
// version, trigger its own removeEntry → writeLedger.
const ids = ['a-cap', 'b-cap', 'c-cap', 'd-cap'];
for (const id of ids) {
recordPending(dir, id, '1.0.0', { kind: 'install', backupName: null, sharedFiles: [] });
// Do NOT create the on-disk dir → safeRmUnder returns true (already gone) → entry rolled back.
}
const { mock } = require('node:test');
let ledgerWrites = 0;
const realWrite = ledgerMod.writeLedger.bind(ledgerMod);
const writeMock = mock.method(ledgerMod, 'writeLedger', function (rd, ledger) {
ledgerWrites++;
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 removeMock = mock.method(ledgerMod, 'removeEntry', function () {
removeEntryCalls++;
return false; // not delegated on purpose — see TV-10 note above.
});
t.after(() => { writeMock.mock.restore(); removeMock.mock.restore(); });
const report = lifecycle.reconcileCapabilities({ runtimeDir: dir });
// All N entries must be rolled back.
for (const id of ids) {
assert.ok(report.rolledBack.includes(id), `${id} must be rolled back`);
assert.strictEqual(readLedgerEntry(dir, id), null, `${id} entry must be removed`);
}
// Step-1 must batch: at most ONE ledger write for the step-1 mutations (plus possibly
// the read-only reconcile() at the end does no write). It must be far below N.
assert.ok(ledgerWrites <= 1,
`step-1 must write the ledger at most once for N=${ids.length} pending entries; writes=${ledgerWrites}`);
assert.strictEqual(removeEntryCalls, 0,
`step-1 batching must not call removeEntry per entry; calls=${removeEntryCalls}`);
});
// ---------------------------------------------------------------------------
// W-6 (NIT): reconcile's removeEntry/recordInstall calls can now throw (strict).
// One bad entry must NOT abort the whole reconcile — it must warn and continue.
// Revert-fails: remove the per-entry try/catch around the rollback mutations →
// a throw on the first entry propagates out of the loop, so the SECOND (good)
// entry is never rolled back and a warning is never recorded; the test's
// "good entry still rolled back" + "warning recorded" assertions fail.
// ---------------------------------------------------------------------------
test('W-6: a throwing per-entry mutation does not abort reconcile (warns and continues)', (t) => {
const dir = runtime();
// 'bad-cap' is an uncommitted UPGRADE with a backup; we make a per-entry filesystem call throw
// for ITS backup path only. 'good-cap' is an uncommitted FRESH install that must still roll back.
const badBackup = 'bad-cap.upgrading-111-222';
seedCapDir(dir, badBackup, declarativeCap('bad-cap', '1.0.0'));
recordPending(dir, 'bad-cap', '1.0.0', { kind: 'upgrade', backupName: badBackup, sharedFiles: [] });
recordPending(dir, 'good-cap', '1.0.0', { kind: 'install', backupName: null, sharedFiles: [] });
const { mock } = require('node:test');
const realExists = fs.existsSync.bind(fs);
const badBackupPath = path.join(dir, '.gsd', 'capabilities', badBackup);
// existsSync(backupDir) is a direct per-entry call in reconcile's step-1 loop (outside the inner
// restore try/catch) — making it throw for bad-cap exercises the W-6 per-entry catch.
const existsMock = mock.method(fs, 'existsSync', function (p) {
if (typeof p === 'string' && path.resolve(p) === path.resolve(badBackupPath)) {
throw new Error('simulated per-entry IO failure for bad-cap');
}
return realExists(p);
});
t.after(() => existsMock.mock.restore());
let report;
assert.doesNotThrow(
() => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); },
'a throwing per-entry mutation must not abort the whole reconcile',
);
// The GOOD entry must still be rolled back despite the bad one throwing.
assert.ok(report.rolledBack.includes('good-cap'),
`good-cap must still be rolled back after bad-cap threw; rolledBack=${JSON.stringify(report.rolledBack)}`);
assert.strictEqual(readLedgerEntry(dir, 'good-cap'), null, 'good-cap entry removed');
// The bad entry must be LEFT in place (not silently committed) for a later retry.
assert.ok(readLedgerEntry(dir, 'bad-cap'), 'bad-cap entry must remain (not silently dropped)');
// A warning must record the bad entry.
assert.ok(Array.isArray(report.warnings) && report.warnings.some((w) => /bad-cap/.test(w)),
`a warning must name the failed entry; warnings=${JSON.stringify(report.warnings)}`);
});
// ---------------------------------------------------------------------------
// W-3 / DUR-5 (LOW): reconcile step-2 must sweep stale `.gsd-capabilities.json.tmp.*`
// orphan temp files (older than a threshold) from the runtime dir.
// Revert-fails: remove the stale-temp sweep → the old orphan temp file remains
// after reconcile, so the "orphan removed" assertion fails.
// ---------------------------------------------------------------------------
test('W-3/DUR-5: reconcile sweeps a stale .gsd-capabilities.json.tmp.* orphan from the runtime dir', () => {
const dir = runtime();
fs.mkdirSync(dir, { recursive: true });
// A committed, valid ledger so reconcile proceeds past the corruption preflight.
ledgerMod.recordInstall(dir, { id: 'z', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] });
// Plant a STALE orphan temp (older than the 5-min threshold) and a FRESH one (must be kept).
const staleTmp = path.join(dir, `${ledgerMod.LEDGER_FILE_NAME}.tmp.99999-deadbeef`);
const freshTmp = path.join(dir, `${ledgerMod.LEDGER_FILE_NAME}.tmp.99998-cafef00d`);
fs.writeFileSync(staleTmp, 'orphan');
fs.writeFileSync(freshTmp, 'fresh');
const old = new Date(Date.now() - 10 * 60 * 1000);
fs.utimesSync(staleTmp, old, old);
lifecycle.reconcileCapabilities({ runtimeDir: dir });
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(escapeRegex(home)), 'the warning names the consent store path');
});
// ---------------------------------------------------------------------------
// #1463: outdatedCapabilities (ADR-1244 D6 "Update available?")
// ---------------------------------------------------------------------------
/** Plant a ledger entry with a given source string and installed version. */
function plantEntry(dir, id, version, source) {
ledgerMod.recordInstall(dir, {
id, version, source, integrity: '',
files: [`.gsd/capabilities/${id}`], sharedEdits: [],
});
}
/** A git ls-remote --tags line for a tag. */
function lsLine(tag) {
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
}
test('#1463 outdated: empty ledger → empty records (no throw)', () => {
const dir = runtime();
assert.deepStrictEqual(lifecycle.outdatedCapabilities({ runtimeDir: dir }), []);
});
test('#1463 outdated: git source with a newer tag → status outdated; latest reported', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.2.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
// revert-fails: an inverted comparison would report 'current' here.
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.2.0');
assert.strictEqual(rec.current, '1.0.0');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git installed == latest → current', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '1.2.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.2.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: git installed > latest → current (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'gitcap', '2.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm newer → outdated; npm peek error → unknown, other caps still reported', () => {
const dir = runtime();
plantEntry(dir, 'npmgood', '1.0.0', 'npm:@org/good@^1');
plantEntry(dir, 'npmbad', '1.0.0', 'npm:@org/bad@^1');
// revert-fails: without timeout/error→unknown handling, the failing peek crashes the whole verb and
// npmgood would never be reported.
// #1463: the good peek returns npm's REAL multi-line range output (one line per matching version); the
// highest version satisfying `^1` is 1.9.0 (a 2.x would be OUT of range and must NOT be chosen).
const fakeNpm = (args) => {
const pkg = args[args.indexOf('view') + 2]; // ['view','--',<pkg>,'version']
if (pkg === '@org/good@^1') {
const out = ["@org/good@1.4.0 '1.4.0'", "@org/good@1.9.0 '1.9.0'"].join('\n') + '\n';
return { exitCode: 0, stdout: out, stderr: '', signal: null, error: null };
}
return { exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null };
};
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.npmgood.status, 'outdated');
assert.strictEqual(byId.npmgood.latest, '1.9.0', 'highest version satisfying the recorded ^1 range');
assert.strictEqual(byId.npmbad.status, 'unknown', 'a failing peek degrades that row only');
assert.strictEqual(recs.length, 2, 'both capabilities are still reported');
});
test('#1463 outdated: tarball → manual; registry → unknown', () => {
const dir = runtime();
plantEntry(dir, 'tarcap', '1.0.0', 'https://host/path/cap-1.0.0.tgz');
plantEntry(dir, 'regcap', '1.0.0', 'my-cap@gsd-registry');
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.tarcap.status, 'manual');
assert.strictEqual(byId.regcap.status, 'unknown');
});
test('#1463 outdated: local newer/equal → outdated/current (re-read of recorded path)', () => {
const dir = runtime();
const srcNew = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localnew-'));
const srcSame = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localsame-'));
cleanups.push(srcNew, srcSame);
fs.writeFileSync(path.join(srcNew, 'capability.json'), JSON.stringify(declarativeCap('locnew', '2.0.0')));
fs.writeFileSync(path.join(srcSame, 'capability.json'), JSON.stringify(declarativeCap('locsame', '1.0.0')));
plantEntry(dir, 'locnew', '1.0.0', srcNew);
plantEntry(dir, 'locsame', '1.0.0', srcSame);
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
assert.strictEqual(byId.locnew.status, 'outdated');
assert.strictEqual(byId.locnew.latest, '2.0.0');
assert.strictEqual(byId.locsame.status, 'current');
});
test('#1463 outdated: git source pinned to a commit SHA is NEVER outdated even with a newer remote tag → status pinned', () => {
const dir = runtime();
// A `#sha:<commit>`-pinned source: `update` re-resolves to the SAME commit, so a newer tag at the
// remote is irrelevant. revert-fails: without the parsed.ref pinned check, peek returns the highest
// tag 'ok' and outdatedCapabilities compares 9.9.9 > 1.0.0 ⇒ 'outdated', failing this 'pinned' assert.
plantEntry(dir, 'pinnedsha', '1.0.0', 'https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v9.9.9')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
assert.strictEqual(rec.sourceKind, 'git');
});
test('#1463 outdated: git source pinned to an explicit tag → status pinned (not outdated)', () => {
const dir = runtime();
plantEntry(dir, 'pinnedtag', '1.0.0', 'https://github.com/org/repo.git#tag:v1.0.0');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v2.0.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'pinned');
});
test('#1463 outdated: UNPINNED git source (no #ref) with a newer tag → still outdated', () => {
const dir = runtime();
plantEntry(dir, 'unpinned', '1.0.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.5.0')].join('\n'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.5.0');
});
test('#1463 outdated: UNPINNED git source at latest → current', () => {
const dir = runtime();
plantEntry(dir, 'unpinnedcur', '1.5.0', 'https://github.com/org/repo.git');
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm RANGE source picks highest matching (real multi-line output) → outdated when installed below it', () => {
const dir = runtime();
plantEntry(dir, 'npmrange', '1.0.0', 'npm:@org/cap@^1');
// npm's REAL range output: one annotated line per matching version (not a single bare token).
// revert-fails: the old single-token parse degrades this to 'unknown', so the 'outdated' assert fails.
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '1.10.0', 'highest matching version (numeric, not lexical) is what update installs');
});
test('#1463 outdated: npm RANGE source installed == highest matching → current', () => {
const dir = runtime();
plantEntry(dir, 'npmrangecur', '1.10.0', 'npm:@org/cap@^1');
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'current');
});
test('#1463 outdated: npm NO-version source (tracks latest) → outdated/current via single latest', () => {
const dir = runtime();
plantEntry(dir, 'npmlatest', '1.0.0', 'npm:@org/cap');
const fakeNpm = () => ({ exitCode: 0, stdout: '2.0.0\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'outdated');
assert.strictEqual(rec.latest, '2.0.0');
});
test('#1463 outdated: npm EXACT-pinned source (@1.2.3) → status pinned (update will not move it)', () => {
const dir = runtime();
plantEntry(dir, 'npmpinned', '1.2.3', 'npm:@org/cap@1.2.3');
// revert-fails: without the exact-pin → 'pinned' branch, peek runs npm view and the row classifies
// by comparison; a registry that advertised 9.9.9 would render it 'outdated', failing this assert.
const fakeNpm = () => ({ exitCode: 0, stdout: '9.9.9\n', stderr: '', signal: null, error: null });
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
assert.strictEqual(rec.status, 'pinned');
});