Files
msd-core/gsd-core/bin/lib/capability-command-router.cjs
Tom Boucher b302f53ee6 refactor(#2368): extract capability arm to capability-command-router (ADR-2346 P2) (#2370)
* refactor(#2368): extract capability arm to capability-command-router (ADR-2346 P2)

Behavior-preserving relocation of the 706-line case 'capability': arm from
gsd-tools.cjs into a new hand-authored bin/lib/capability-command-router.cjs
(sibling of ensure-runtime-build.cjs). The 15 bin/-relative require paths are
rewritten to sibling-relative (correct for bin/lib/). dispatchHostCommand is
now async (capability's install/upgrade/consent ops await the lifecycle); sync
routers (state/phase/…) pass through await unchanged. case 'capability':
removed; capability dispatches via HOST_COMMAND_ROUTERS.

Validated by the existing capability-lifecycle / -consent / -trust / -loader
test suites (no logic changed). Golden install-parity fixtures regenerated.

Closes #2368 (Slice 1 — relocation). Probe consolidation (capHostVersion→
readHostVersion, capReadStrict dedup) deferred to a follow-up slice.

* fix(#2368): add capabilityState/capabilityWriter requires + INVENTORY row

The relocated capability arm references capabilityState (cmdCapabilityState,
resolveCapabilityRuntimeState) and capabilityWriter (cmdCapabilitySet) — both
module-scope requires in gsd-tools.cjs (L288/289) that the initial closure-dep
scan missed. Added as sibling requires to capability-command-router.cjs. Also
adds the new cli module to docs/INVENTORY.md + regenerates the manifest.

* fix(#2368): correct capHostVersion __dirname depth for bin/lib/ relocation

capHostVersion's VERSION/package.json paths were bin/-relative ('..' and
'..','..'); on relocation to bin/lib/ they resolved one level too deep,
so capHostVersion returned 0.0.0 and capability install failed the
engines.gsd gate (#1920). Added one more '..' to each (now resolves
gsd-core/VERSION and repo-root package.json correctly).

* test(#2368): drop capability from the invocation loop (async/FS vs /fake/cwd)

capability is async and does FS/config reads, so invoking it against the
unit test's /fake/cwd is fragile. The 6 sync Tier-1 routers stay in the
invocation loop; capability is covered by the non-invoking registry-
ownership assertion + the dedicated capability-* test suites.

* chore: retrigger CI (no-changelog label now present)
2026-07-17 11:04:47 -04:00

734 lines
39 KiB
JavaScript

'use strict';
/**
* capability-command-router.cjs — ADR-2346 P2 (#2368).
*
* Behavior-preserving relocation of the former `case 'capability':` arm from
* gsd-tools.cjs (gsd-tools.cjs:1693..2399, pre-cutover). Owns the capability
* lifecycle CLI (state/list/install/upgrade/remove/consent/trust) and wires the
* capability-lifecycle / -trust / -consent / -ledger / -loader modules.
*
* Dispatched via HOST_COMMAND_ROUTERS.capability in runCommand's default case
* (host dispatch table, ADR-2346 Layer 2). Hand-authored CJS (sibling of
* ensure-runtime-build.cjs) — not a generated .cts, so it is committed directly.
*
* NOTE: the require() paths below are sibling-relative (./X.cjs), correct for
* this file's home in bin/lib/ — rewritten from the arm's original ./lib/X.cjs
* (which resolved relative to bin/gsd-tools.cjs).
*/
const fs = require('node:fs');
const path = require('node:path');
const io = require('./io.cjs');
const { output, error, ERROR_REASON } = io;
const { ExitError } = require('./cli-exit.cjs');
const capabilityState = require('./capability-state.cjs');
const capabilityWriter = require('./capability-writer.cjs');
async function routeCapabilityCommand({ args, cwd, raw }) {
// capability state [--config-dir <path>]
// Root resolution: 'capability' is NOT in SKIP_ROOT_RESOLUTION for the
// same reason 'loop' is not: both are registry/config queries that need
// the project root (cwd) for .planning/config.json activation resolution.
// If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
// be added at the same time to keep them consistent.
const capSubcommand = args[1];
// --- Capability management CLI helpers (ADR-1244 D5/D6; install/update/remove/list/disable/enable).
// Pure arg parsing + scope/config/host-version resolution. The lifecycle modules themselves are
// lazy-required inside each mutating branch so the common state/set paths never load them. ---
const capFlagValue = (name) => {
const i = args.indexOf(name);
if (i === -1) return undefined;
const v = args[i + 1];
if (!v || v.startsWith('--')) {
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
return v;
};
const capHasFlag = (name) => args.includes(name);
const capRepeatedFlag = (name) => {
const out = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === name) {
const v = args[i + 1];
if (!v || v.startsWith('--')) {
error(`Missing value for ${name}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
out.push(v);
i++; // skip the consumed value
}
}
return out;
};
// Resolve a --scope value to the lifecycle runtimeDir — the scope ROOT that holds
// .gsd/capabilities/<id> and the .gsd-capabilities.json ledger, matching capability-loader's
// read paths exactly (global → $GSD_HOME||home; project → the resolved project root). For the
// project scope this is just `cwd`: the outer dispatch already resolved cwd to the project root
// via findProjectRoot (capability is NOT in SKIP_ROOT_RESOLUTION), so no second resolve is needed.
// Note: the strict_known_registries policy (capReadStrict) is read from the PROJECT config
// regardless of --scope — it is a project-scoped policy; there is no machine-wide source allowlist.
const capResolveScope = (scope) => {
const s = scope || 'global';
if (s !== 'global' && s !== 'project') {
error(`Invalid --scope "${s}": expected global or project`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
if (s === 'project') return { scope: 'project', runtimeDir: cwd };
const os = require('node:os');
return { scope: 'global', runtimeDir: process.env.GSD_HOME || os.homedir() };
};
// capabilities.strict_known_registries policy (null=permissive, []=lockdown, [hosts]=allowlist).
// loadConfig's whitelist does not surface this key, so read config.json directly (drift-guard pattern);
// undefined => the lifecycle's permissive default. The raw value is passed THROUGH verbatim — a
// malformed (non-array, non-null) value must reach the trust gate so it can fail CLOSED, not be
// silently downgraded to permissive here.
const capReadStrict = () => {
let cfgPath;
try {
const { planningDir } = require('./planning-workspace.cjs');
cfgPath = path.join(planningDir(cwd), 'config.json');
} catch {
return undefined; // cannot even resolve the project config dir — permissive default
}
if (!fs.existsSync(cfgPath)) return undefined; // no project config — permissive default
let cfg;
try {
cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
} catch {
// Config is PRESENT but unreadable/unparseable: a security policy must not silently
// downgrade to permissive. Fail CLOSED — lockdown ([]) blocks external installs (local
// still allowed) until the config is fixed.
return [];
}
if (cfg && cfg.capabilities && Object.prototype.hasOwnProperty.call(cfg.capabilities, 'strict_known_registries')) {
return cfg.capabilities.strict_known_registries;
}
return undefined;
};
// Running GSD version (hard gate for engines.gsd at install/load); fail-closed to 0.0.0.
// #1920: prefer the authoritative gsd-core/VERSION the installer writes for EVERY runtime
// (gsd-core/bin/ -> ../VERSION), so installed layouts report the true version even when the
// walked-up ../../package.json is the versionless CommonJS marker or the user's own project.
// Fall back to the runtime-root package.json (dev/source tree), then fail-closed. Mirrors
// readHostVersion() in capability-loader.cts.
const capHostVersion = () => {
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
try {
const v = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
if (SEMVER_PREFIX.test(v)) return v;
} catch { /* not an installed tree (no gsd-core/VERSION) */ }
try {
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json')); // gsd-core/bin/lib/ -> repo root is three up
if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version;
} catch { /* runtime root has no package.json */ }
return '0.0.0';
};
// #1459: the USER-OWNED consent home (GSD_HOME||homedir()) where project-scope consent records
// live — OUTSIDE any repo. SAME rule as the loader/consent-store path resolution so a record
// written here is the record the loader checks.
const capConsentHome = () => {
const osMod = require('node:os');
return process.env.GSD_HOME || osMod.homedir();
};
// #1459: realpath(cwd) — the canonical PROJECT ROOT used to bind/lookup a project consent
// record (the consent store realpaths it too, so loader + CLI agree). Best-effort: cwd if the
// path cannot be realpath'd (e.g. it does not exist yet).
const capProjectRoot = () => {
try { return fs.realpathSync(cwd); } catch { return cwd; }
};
// UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports
// (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous
// bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during
// reconcile was invisible. We never abort on a reconcile warning here — the mutating op that
// follows runs its own fail-closed checks — but the warning must be OBSERVABLE.
// #1459 IC-03: pass scope + the user-owned consent home so a rollback that DELETES a committed/
// half-committed PROJECT-scope entry whose bundle dir is gone also REVOKES the now-stale consent
// record (an identical re-drop then stays inactive until re-consented). Global scope / no store →
// reconcile revokes nothing.
const capRunReconcile = (runtimeDir, lifecycle, scope) => {
try {
const report = lifecycle.reconcileCapabilities({ runtimeDir, scope, consentStoreDir: capConsentHome() });
if (report && Array.isArray(report.warnings)) {
for (const w of report.warnings) {
try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ }
}
}
} catch { /* best-effort crash recovery — never block the op on a reconcile failure */ }
};
if (capSubcommand === 'state') {
const configDirIdx = args.indexOf('--config-dir');
let configDir = null;
if (configDirIdx !== -1) {
const configDirVal = args[configDirIdx + 1];
// Validate that --config-dir has a following non-flag value.
if (!configDirVal || configDirVal.startsWith('--')) {
error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
configDir = configDirVal;
}
const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
// --runtime <r> (#2003): explicit runtime override so the config-dir
// resolution bypasses the persisted-runtime fallback. Dual-form like
// --config-dir (--runtime X / --runtime=X).
let stateRuntime = undefined;
const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime='));
const stateRuntimeIdx = args.indexOf('--runtime');
if (stateRuntimeEqArg) {
const value = stateRuntimeEqArg.slice('--runtime='.length).trim();
if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
stateRuntime = value;
} else if (stateRuntimeIdx !== -1) {
const value = args[stateRuntimeIdx + 1];
if (!value || value.startsWith('--')) {
error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
stateRuntime = value;
}
capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime });
} else if (capSubcommand === 'set') {
// capability set <id> [--on|--off|--enable|--disable] [--gate <key>=<bool>]... [--config-dir <dir>] [--runtime <r>] [--scope <s>]
const capId = args[2];
if (!capId || capId.startsWith('--')) {
error('Missing capability id for: capability set <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// Parse --config-dir
const setConfigDirIdx = args.indexOf('--config-dir');
let setConfigDir = null;
if (setConfigDirIdx !== -1) {
const setConfigDirVal = args[setConfigDirIdx + 1];
if (!setConfigDirVal || setConfigDirVal.startsWith('--')) {
error('Missing value for --config-dir', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
setConfigDir = setConfigDirVal;
}
const resolvedSetConfigDir = setConfigDir ? path.resolve(setConfigDir) : null;
// Parse --on/--enable and --off/--disable (mutually exclusive)
const hasOn = args.includes('--on') || args.includes('--enable');
const hasOff = args.includes('--off') || args.includes('--disable');
if (hasOn && hasOff) {
error('Conflicting flags: --on/--enable and --off/--disable cannot both be present', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
let setEnabled;
if (hasOn) {
setEnabled = true;
} else if (hasOff) {
setEnabled = false;
}
// Parse --gate <key>=<bool> (repeatable)
const setGates = {};
for (let gi = 0; gi < args.length; gi++) {
if (args[gi] === '--gate') {
const gateVal = args[gi + 1];
if (!gateVal || gateVal.startsWith('--')) {
error('Missing value for --gate (expected <key>=<true|false>)', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const eqIdx = gateVal.indexOf('=');
if (eqIdx === -1) {
error(`Malformed --gate value "${gateVal}": expected <key>=<true|false>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const gateKey = gateVal.slice(0, eqIdx);
const gateBoolStr = gateVal.slice(eqIdx + 1);
if (gateBoolStr !== 'true' && gateBoolStr !== 'false') {
error(`Malformed --gate value "${gateVal}": bool must be true or false`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
setGates[gateKey] = gateBoolStr === 'true';
gi++; // skip consumed value
}
}
// Parse --runtime and --scope (validate that values are present and not flags)
const runtimeIdx = args.indexOf('--runtime');
let setRuntime;
if (runtimeIdx !== -1) {
const runtimeVal = args[runtimeIdx + 1];
if (!runtimeVal || runtimeVal.startsWith('--')) {
error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
setRuntime = runtimeVal;
}
const scopeIdx = args.indexOf('--scope');
let setScope;
if (scopeIdx !== -1) {
const scopeVal = args[scopeIdx + 1];
if (!scopeVal || scopeVal.startsWith('--')) {
error('Missing value for --scope', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
setScope = scopeVal;
}
capabilityWriter.cmdCapabilitySet(
cwd,
resolvedSetConfigDir,
capId,
{ enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
raw,
);
} else if (capSubcommand === 'install') {
// capability install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…
const spec = args[2];
if (!spec || spec.startsWith('--')) {
error('Missing <spec> for: capability install <spec>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./capability-lifecycle.cjs');
const trust = require('./capability-trust.cjs');
// Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution,
// staging, or any shared-config write — so an over-cap install fails fast with a clear count
// error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in
// depth); this CLI-side guard short-circuits before even the pre-op reconcile runs.
const installSharedFiles = capRepeatedFlag('--shared-file');
const ledgerModInstall = require('./capability-ledger.cjs');
if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) {
error(
`capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` +
`exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`,
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
}
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
const res = await lifecycle.installCapability(spec, {
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
integrity: capFlagValue('--integrity'),
sharedFiles: installSharedFiles,
strictKnownRegistries: capReadStrict(),
// #1459: bind a user consent record for a CONSENTED project install (under the user-owned
// consent home, NOT in the repo). The lifecycle records nothing for global scope.
scope,
consentStoreDir: capConsentHome(),
});
if (res.status === 'installed') {
output({
status: 'installed',
id: res.id,
version: res.version,
scope,
disclosure: trust.summarizeDisclosure(res.disclosure || {}),
}, raw);
} else if (res.status === 'aborted') {
// 'aborted' always means "executable surface needs consent" in the lifecycle contract —
// match it regardless of the requiresConsent flag so a future aborted path can't fall
// through to the generic "blocked: unknown reason" arm with a misleading message.
const disclosure = trust.summarizeDisclosure(res.disclosure || {});
// UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation
// can detect the consent requirement programmatically. We throw ExitError (not error(),
// which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout
// is flushed before exit; the human-readable guidance still lands on stderr.
output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw);
throw new ExitError(
1,
['Error: This capability declares executable surfaces and needs your consent before install:']
.concat(disclosure.map((l) => ' ' + l))
.concat(['Re-run with --yes to grant consent and install.'])
.join('\n'),
);
} else {
error(
`capability install blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`,
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
);
}
} else if (capSubcommand === 'update') {
// capability update [<id> | --all] [--scope global|project] [--yes] [--shared-file <rel>]…
const all = capHasFlag('--all');
const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined;
if (!all && !id) {
error('capability update requires <id> or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
if (all && id) {
error('capability update: pass either <id> or --all, not both', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./capability-lifecycle.cjs');
const ledgerMod = require('./capability-ledger.cjs');
const trust = require('./capability-trust.cjs');
// Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE
// the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then
// re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with
// a clear count error and leaves no reconcile side-effects, mirroring the install dispatch.
const updateSharedFiles = capRepeatedFlag('--shared-file');
if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) {
error(
`capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` +
`exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`,
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
}
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
// readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
// corrupt-but-present ledger fails closed rather than silently reporting not_installed (<id>)
// or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2).
let ledger;
try {
ledger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
const entries = (ledger && ledger.entries) || {};
const upgradeOne = async (capId) => {
const entry = entries[capId];
if (!entry) return { id: capId, status: 'not_installed' };
// expectedId pins the op to the requested id: a retargeted/edited source that now resolves
// to a different manifest id is refused by the lifecycle rather than upgrading the wrong cap.
const r = await lifecycle.upgradeCapability(entry.source, {
runtimeDir,
hostVersion: capHostVersion(),
consentGranted: capHasFlag('--yes'),
sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile
strictKnownRegistries: capReadStrict(),
expectedId: capId,
// #1459: re-record the project consent for the upgraded bundle (new integrity/signature).
scope,
consentStoreDir: capConsentHome(),
});
// UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes
// them as null rather than omitting them (JSON.stringify drops undefined keys), giving a
// stable per-entry shape for `--all` consumers.
return {
id: capId,
status: r.status,
fromVersion: r.fromVersion ?? null,
toVersion: r.toVersion ?? null,
requiresConsent: r.requiresConsent ?? null,
blockReasons: r.blockReasons ?? null,
disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null,
};
};
if (all) {
// Sequential by design: each upgrade takes the per-scope capability lock; parallel
// runs would contend on the ledger/lock (mirrors the worktree config.lock policy).
const results = [];
for (const capId of Object.keys(entries)) {
results.push(await upgradeOne(capId));
}
const failed = results.filter((x) => x.status !== 'upgraded');
if (failed.length > 0) {
// UX-1: emit the FULL structured result on STDOUT first (success and partial-failure
// alike), then set a non-zero exit. Previously the results JSON was embedded inside the
// error STRING on stderr, so automation could not parse a partial-failure run as
// structured data. We throw ExitError (not error(), which calls process.exit and would
// bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a
// concise reason still lands on stderr.
output({ scope, updated: results }, raw);
throw new ExitError(
1,
`Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` +
`(see the JSON result on stdout for per-capability status).`,
);
}
output({ scope, updated: results }, raw);
} else {
const r = await upgradeOne(id);
if (r.status === 'upgraded') {
output({ status: 'upgraded', id: r.id, fromVersion: r.fromVersion, toVersion: r.toVersion, scope, disclosure: r.disclosure }, raw);
} else if (r.status === 'not_installed') {
error(`capability "${id}" is not installed in ${scope} scope; use: capability install`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
} else if (r.status === 'aborted') {
// 'aborted' always means "needs consent" (see install) — handle it independently of the
// requiresConsent flag so it never falls through to the generic blocked arm.
error(
[`capability update for "${id}" changes its executable surface and needs your consent:`]
.concat((r.disclosure || []).map((l) => ' ' + l))
.concat(['Re-run with --yes to grant consent and update.'])
.join('\n'),
ERROR_REASON ? ERROR_REASON.USAGE : undefined,
);
} else {
error(`capability update blocked: ${(r.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
}
} else if (capSubcommand === 'remove') {
// capability remove <id> [--purge-data] [--scope global|project]
const id = args[2];
if (!id || id.startsWith('--')) {
error('Missing <id> for: capability remove <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope'));
const lifecycle = require('./capability-lifecycle.cjs');
const ledgerMod = require('./capability-ledger.cjs');
capRunReconcile(runtimeDir, lifecycle, scope); // UX-2: surface reconcile warnings on stderr
// Ledger first: an installed overlay is removable even if its id shadows a first-party name.
// Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo).
// Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than
// silently reporting "first-party cannot be removed" for any id (finding 7).
let removeLedger;
try {
removeLedger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id));
if (!inLedger) {
const base = require('./capability-loader.cjs').loadRegistry();
if (base && base.capabilities && Object.prototype.hasOwnProperty.call(base.capabilities, id)) {
error(`"${id}" is a first-party capability and cannot be removed here; use the product uninstaller (gsd --uninstall)`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
}
const res = lifecycle.removeCapability(id, {
runtimeDir,
removeData: capHasFlag('--purge-data'),
// #1459: a project-scope removal revokes the user consent record so a later repo-dropped
// bundle of the same id cannot silently re-activate against a stale consent.
scope,
consentStoreDir: capConsentHome(),
});
if (res.status === 'removed') {
// #1459 finding 3: a project removal whose consent revoke FAILED (e.g. the consent-store lock
// could not be acquired) is a NON-CLEAN removal — the bundle/ledger are gone but a STALE consent
// record remains. Surface it on stderr + in the JSON so the user knows to clear it.
if (res.consentRevokeFailed) {
process.stderr.write(`warning: ${res.consentRevokeWarning || `consent record for "${id}" could not be revoked; clear it with: gsd capability trust revoke ${id}`}\n`);
}
output({
status: 'removed',
id,
scope,
removedFiles: res.removedFiles,
strippedEdits: res.strippedEdits,
dataPreserved: res.dataPreserved,
consentRevokeFailed: res.consentRevokeFailed || undefined,
consentRevokeWarning: res.consentRevokeWarning || undefined,
}, raw);
} else if (res.status === 'not_installed') {
error(`capability "${id}" is not installed in ${scope} scope`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
} else {
error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
} else if (capSubcommand === 'list') {
// capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors.
// When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a
// corrupt unrelated ledger in another scope does not block a scoped list).
const loader = require('./capability-loader.cjs');
const ledgerMod = require('./capability-ledger.cjs');
const semver = require('./semver-compare.cjs');
const host = capHostVersion();
const rows = [];
const listScopeArg = capFlagValue('--scope');
// Validate --scope if provided.
if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') {
error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// First-party capabilities are always included (they have no scope concept).
const base = loader.loadRegistry();
const fp = (base && base.capabilities) || {};
// #1459: consult the composed overlay's warnings so a DISCOVERED-BUT-INACTIVE project overlay
// (a bundle whose project ledger looks committed but has no user consent record on THIS
// machine) is marked status:'inactive' with a reason, instead of silently appearing active.
// loadRegistry is non-throwing; a failure here just leaves rows un-annotated.
const inactiveById = {};
try {
const composed = loader.loadRegistry({ includeInstalled: true, cwd });
const overlayWarnings = (composed && composed._overlay && composed._overlay.warnings) || [];
for (const w of overlayWarnings) {
// #1459 IC-02: classify by the STRUCTURAL discriminant `kind`, not by matching the
// human-readable reason prose (which is free to change without breaking this filter).
if (w && typeof w.id === 'string' && w.kind === 'unconsented') {
inactiveById[`${w.scope} ${w.id}`] = w.reason;
}
}
} catch { /* best-effort — list still works without the inactive annotation */ }
// Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the
// SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so
// `list` and `state` stop disagreeing. `list` previously derived `status`
// purely from ledger-entry existence — an installed-but-not-surfaced cap
// reported active in `list` and absent in `state`. Surfaced is evaluated at
// the default runtime config dir (the resolver resolves it when undefined),
// matching `capability state <id>` with no --config-dir. Best-effort: a
// resolver failure leaves surfacedById empty (rows report surfaced:null).
const surfacedById = {};
// surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface
// state is single-source — one runtime config dir → one .gsd-surface.json
// → one surfaced truth per capId — and the loader dedupes overlay caps to
// one registry entry per id (first-party-wins). So a cap installed in both
// scopes correctly shares one surfaced value across its list rows.
try {
const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined);
for (const cap of (surfaceState && surfaceState.capabilities) || []) {
if (cap && typeof cap.id === 'string') {
surfacedById[cap.id] = cap.surfaced === true;
}
}
} catch { /* best-effort — list still works without the surfaced annotation */ }
for (const capId of Object.keys(fp)) {
const cap = fp[capId] || {};
rows.push({
id: capId,
role: cap.role || null,
version: cap.version || null,
tier: cap.tier || null,
source: 'first-party',
scope: 'first-party',
status: 'active',
surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
title: cap.title || null,
});
}
// Overlay scopes: honor --scope to read only the requested scope (finding 8).
const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project'];
for (const sc of overlayScopes) {
const { runtimeDir } = capResolveScope(sc);
// readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError
// when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a
// corrupt-but-present ledger is visible to the user (blocked/error) rather than silently
// dropping overlay entries and returning a first-party-only list (site A fix, #1462).
let ledger;
try {
ledger = ledgerMod.readLedgerStrict(runtimeDir);
} catch (err) {
// UX-3: name the offending scope so the user knows WHICH ledger to fix.
error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined);
}
if (!ledger || !ledger.entries) continue;
for (const capId of Object.keys(ledger.entries)) {
const entry = ledger.entries[capId];
let manifest = {};
try {
// #1459 CONVERGENCE finding 2: read the (project-plantable) capability.json via the SHARED
// bounded fd reader (open → fstat → require regular file → size cap → read exactly size), NOT
// a raw fs.readFileSync which BLOCKS forever on a repo-planted FIFO/device manifest and reads
// an oversized manifest unbounded into memory (OOM). 8 MiB is wildly more than any real
// declarative capability.json. A null (genuinely missing) or a bounded-reader throw
// (non-regular/oversized/IO) → leave manifest = {} so the entry is LISTED but with no metadata
// (null role/tier/title) rather than hanging the list — `capability list` still exits cleanly.
const raw = ledgerMod.readSmallRegularFile(path.join(runtimeDir, '.gsd', 'capabilities', capId, 'capability.json'), 8 * 1024 * 1024);
manifest = raw === null ? {} : JSON.parse(raw);
} catch { manifest = {}; }
let status = 'active';
let reason = null;
const range = manifest.engines && manifest.engines.gsd;
if (typeof range === 'string' && range && !semver.semverSatisfies(host, range)) status = 'incompatible';
// #1459: a project overlay with no user consent record is DISCOVERED-BUT-INACTIVE.
const inactiveReason = inactiveById[`${sc} ${capId}`];
if (inactiveReason) { status = 'inactive'; reason = inactiveReason; }
rows.push({
id: capId,
role: manifest.role || null,
version: entry.version || null,
tier: manifest.tier || null,
source: entry.source || null,
scope: sc,
status,
reason,
// Issue #2045 (DEFECT 3): surfaced reflects surface composition, so
// list and state agree. An inactive (unconsented/incompatible) cap is
// surfaced:false by definition; otherwise defer to the resolver.
surfaced: status === 'active'
? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null)
: false,
title: manifest.title || null,
});
}
}
output(rows, raw || capHasFlag('--json'));
} else if (capSubcommand === 'disable' || capSubcommand === 'enable') {
// capability disable|enable <id> — toggles activation state (same mechanism as: capability set <id> --off|--on).
const id = args[2];
if (!id || id.startsWith('--')) {
error(`Missing <id> for: capability ${capSubcommand} <id>`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const dCfg = capFlagValue('--config-dir');
capabilityWriter.cmdCapabilitySet(
cwd,
dCfg ? path.resolve(dCfg) : null,
id,
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
raw,
);
} else if (capSubcommand === 'outdated') {
// capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
// For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
// latest available version and report whether a newer one exists. This never re-clones/re-packs;
// a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
const lifecycle = require('./capability-lifecycle.cjs');
const outdatedScopeArg = capFlagValue('--scope');
if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
const records = [];
for (const sc of outdatedScopes) {
const { runtimeDir } = capResolveScope(sc);
// outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
for (const r of scRecords) records.push({ ...r, scope: sc });
}
const asJson = raw || capHasFlag('--json');
if (asJson) {
output(records, false); // machine output: the records array (JSON).
} else {
// Human-readable table: ID | Source | Current | Latest | Status.
const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
const cell = (v) => (v === null || v === undefined ? '-' : String(v));
const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
for (const row of tableRows) lines.push(fmt(row));
if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
output(records, true, lines.join('\n') + '\n');
}
} else if (capSubcommand === 'trust') {
// capability trust list [--scope project] [--json]
// capability trust revoke <id> [--project <path>]
// The user-owned consent store (#1459) gates PROJECT-scope third-party capability activation.
const consentMod = require('./capability-consent.cjs');
const trustSub = args[2];
if (trustSub === 'list') {
// --scope is accepted for symmetry; only 'project' records exist today.
const listScope = capFlagValue('--scope');
if (listScope && listScope !== 'project') {
error(`Invalid --scope "${listScope}" for trust list: only "project" consent records exist`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
const store = consentMod.readConsentStore(capConsentHome());
const rows = Object.keys(store.records).map((k) => {
const r = store.records[k];
// #1459 IC-09: surface disclosureSignature + contentHash so an operator can diff the STORED
// binding against the current bundle (e.g. `gsd capability list` showing inactive after a
// tamper) and understand why a consented cap deactivated. The contentHash is THE security
// binding the loader checks; disclosureSignature is the executable-surface re-consent key.
return {
id: r.id, scope: r.scope, projectRoot: r.projectRoot,
integrity: r.integrity, disclosureSignature: r.disclosureSignature, contentHash: r.contentHash,
consentedAt: r.consentedAt,
};
});
output(rows, raw || capHasFlag('--json'));
} else if (trustSub === 'revoke') {
const id = args[3];
if (!id || id.startsWith('--')) {
error('Missing <id> for: capability trust revoke <id>', ERROR_REASON ? ERROR_REASON.USAGE : undefined);
}
// --project pins the project root whose consent is revoked; defaults to realpath(cwd).
const projFlag = capFlagValue('--project');
let projectRoot;
try { projectRoot = projFlag ? fs.realpathSync(path.resolve(projFlag)) : capProjectRoot(); }
catch { projectRoot = projFlag ? path.resolve(projFlag) : cwd; }
// #1459 finding 3: revokeProjectConsent THROWS when the consent-store lock cannot be acquired
// (round-3: never do an unlocked read-modify-write). Catch it and emit a CLEAN, actionable
// error rather than letting runMain surface a raw SDK/stack failure. The lifecycle treats a
// consent-write failure as non-fatal, so a clean exit-1 here is the right contract.
try {
consentMod.revokeProjectConsent({ gsdHome: capConsentHome(), projectRoot, id });
} catch (err) {
error(
`capability trust revoke blocked: ${err && err.message ? err.message : String(err)} ` +
`(could not acquire the consent-store lock; another capability operation may be in progress — retry)`,
ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined,
);
}
output({ status: 'revoked', id, projectRoot, scope: 'project' }, raw);
} else {
error(
`Unknown capability trust subcommand: ${trustSub}. Available: list, revoke`,
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
);
}
} else {
error(
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
);
}
}
module.exports = { routeCapabilityCommand };