* 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)
This commit is contained in:
@@ -300,6 +300,7 @@
|
||||
"audit-command-router.cjs",
|
||||
"audit.cjs",
|
||||
"capability-activation.cjs",
|
||||
"capability-command-router.cjs",
|
||||
"capability-consent.cjs",
|
||||
"capability-ledger.cjs",
|
||||
"capability-lifecycle.cjs",
|
||||
|
||||
@@ -404,6 +404,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 |
|
||||
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
|
||||
| `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings |
|
||||
| `capability-command-router.cjs` | ADR-2346 P2 host command router for `gsd-tools capability` — relocated verbatim from the former 706-line `case 'capability':` arm in `gsd-tools.cjs`; dispatched via `HOST_COMMAND_ROUTERS` in `runCommand`'s default case; wires `capability-lifecycle`/`-trust`/`-consent`/`-state`/`-writer`; hand-authored CJS (sibling of `ensure-runtime-build.cjs`) |
|
||||
| `capability-consent.cjs` | User-owned capability consent store (#1459) — bounded, non-throwing JSON store at `${GSD_HOME\|\|homedir()}/.gsd/consent.json` (NEVER under a repo) keyed by `${realpath(projectRoot)} <id>`; exports `consentStorePath`/`readConsentStore`/`hasProjectConsent` (matches iff integrity AND disclosureSignature both match)/`recordProjectConsent` (atomic+durable write)/`revokeProjectConsent`; the authoritative consent signal that gates PROJECT-scope third-party capability activation so a forged/cloned project ledger no longer activates anything until the user consents on THIS machine |
|
||||
| `capability-lock.cjs` | Shared cross-process lock primitive (#1459 finding 4) — the SINGLE hardened lockfile protocol used by BOTH capability-lifecycle (`.gsd/capabilities/.lock`) and capability-consent (`.consent.lock`); exports `acquireLock(lockPath, opts?)`/`releaseLock(handle)` with pid + process-start-time liveness identity, a hard deadman, and token+inode owner-safe release — NEVER stale-steals a verified-live same-host holder, reclaims only a provably-dead/unverifiable holder, never deadlocks; `opts.maxAttempts`/`opts.waitForFresh` let the consent store serialize genuinely-contended writers; `_setLockProbes`/`_resetLockProbes` are test seams |
|
||||
| `capability-ledger.cjs` | Per-runtime install ledger (ADR-1244 D4) — atomic read/write of `.gsd-capabilities.json` recording `{ id, version, source, integrity, files[], sharedEdits[] }` per installed capability; exports `readLedger`/`writeLedger`/`recordInstall`/`removeEntry`/`reconcile` (orphan detection)/`readSmallRegularFile` (utf8) + `readSmallRegularFileBuffer` (raw bytes, the byte-exact consent-hash reader, #1459 finding 1); atomic commit point and reconciliation basis for Phase-4 upgrade/remove |
|
||||
|
||||
@@ -291,6 +291,7 @@ const { routePhaseCommand } = require('./lib/phase-command-router.cjs');
|
||||
const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
|
||||
const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
|
||||
const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs');
|
||||
const { routeCapabilityCommand } = require('./lib/capability-command-router.cjs');
|
||||
const { routeAgentCommand } = require('./lib/agent-command-router.cjs');
|
||||
const smartEntryMod = require('./lib/smart-entry.cjs');
|
||||
const { routeCheckCommand } = require('./lib/check-command-router.cjs');
|
||||
@@ -590,13 +591,18 @@ const HOST_COMMAND_ROUTERS = {
|
||||
try { warnIfStaleBake(ctx.cwd); } catch { /* guard must never break init */ }
|
||||
routeInitCommand({ init, ...ctx });
|
||||
},
|
||||
// capability → routeCapabilityCommand (ADR-2346 P2). The router is async
|
||||
// (install/upgrade/consent ops await the lifecycle); dispatchHostCommand
|
||||
// awaits it. The router imports its own io/cli-exit/deps, so no module
|
||||
// injection needed — it receives {args,cwd,raw} (+error, ignored).
|
||||
capability: routeCapabilityCommand,
|
||||
};
|
||||
|
||||
// Returns true when consumed (suppress "Unknown command"), false to fall
|
||||
// through. Prototype-pollution-safe: own-property lookup rejects
|
||||
// `__proto__`/`constructor`/`prototype` command keys (same guard as
|
||||
// dispatchCapabilityCommand).
|
||||
function dispatchHostCommand({ command, args, cwd, raw, error }) {
|
||||
async function dispatchHostCommand({ command, args, cwd, raw, error }) {
|
||||
if (
|
||||
command === '__proto__' ||
|
||||
command === 'constructor' ||
|
||||
@@ -609,7 +615,9 @@ function dispatchHostCommand({ command, args, cwd, raw, error }) {
|
||||
}
|
||||
const router = HOST_COMMAND_ROUTERS[command];
|
||||
if (typeof router !== 'function') return false;
|
||||
router({ args, cwd, raw, error });
|
||||
// `await` so async host routers (e.g. capability's install/upgrade ops)
|
||||
// complete before runCommand returns; sync routers pass through unchanged.
|
||||
await router({ args, cwd, raw, error });
|
||||
return true; // consumed — don't emit "Unknown command"
|
||||
}
|
||||
|
||||
@@ -1690,713 +1698,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
case 'capability': {
|
||||
// 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('./lib/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/ -> repo root is two 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('./lib/capability-lifecycle.cjs');
|
||||
const trust = require('./lib/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('./lib/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('./lib/capability-lifecycle.cjs');
|
||||
const ledgerMod = require('./lib/capability-ledger.cjs');
|
||||
const trust = require('./lib/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('./lib/capability-lifecycle.cjs');
|
||||
const ledgerMod = require('./lib/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('./lib/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('./lib/capability-loader.cjs');
|
||||
const ledgerMod = require('./lib/capability-ledger.cjs');
|
||||
const semver = require('./lib/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('./lib/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('./lib/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,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase-plan-index': {
|
||||
phase.cmdPhasePlanIndex(cwd, args[1], raw);
|
||||
break;
|
||||
@@ -3216,7 +2517,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
// commands (state, …) routed via their `route*Command` router instead of
|
||||
// a hardcoded `case` arm. Tried after capability/overlay dispatch and
|
||||
// before the unknown-command error.
|
||||
if (dispatchHostCommand({ command, args, cwd, raw, error })) break;
|
||||
if (await dispatchHostCommand({ command, args, cwd, raw, error })) break;
|
||||
|
||||
// #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
|
||||
// above split it so `command` here is the head ("foo"). Use
|
||||
|
||||
733
gsd-core/bin/lib/capability-command-router.cjs
Normal file
733
gsd-core/bin/lib/capability-command-router.cjs
Normal file
@@ -0,0 +1,733 @@
|
||||
'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 };
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "15e42e7e6d7d8c84",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e66d7561ef59d3ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf",
|
||||
"gsd-core/bin/gsd-tools.cjs": "8c4a564592742a98",
|
||||
"gsd-core/bin/gsd-tools.cjs": "a895ba31f85552b5",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "15e42e7e6d7d8c84",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e66d7561ef59d3ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828",
|
||||
"gsd-core/bin/gsd-tools.cjs": "8f23cb9411d49aa6",
|
||||
"gsd-core/bin/gsd-tools.cjs": "39a7e60e50d3aba9",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f",
|
||||
"gsd-core/bin/gsd-tools.cjs": "d7afff9ae7741fd6",
|
||||
"gsd-core/bin/gsd-tools.cjs": "826b3199cfbe2ea4",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
2
tests/fixtures/golden-install-parity/pi.json
vendored
2
tests/fixtures/golden-install-parity/pi.json
vendored
@@ -6,7 +6,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7cc25a154a7dfd6c",
|
||||
"gsd-core/bin/gsd-tools.cjs": "accb02bd8bdae4ff",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527",
|
||||
"gsd-core/bin/gsd-tools.cjs": "d928a772c8e30a5a",
|
||||
"gsd-core/bin/gsd-tools.cjs": "85318fd8778e85c0",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "0efc5ebda7f9b88d",
|
||||
"gsd-core/bin/gsd-tools.cjs": "fd0289f36085bf67",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "49b882b68599fd19",
|
||||
"gsd-core/bin/gsd-tools.cjs": "7e1dab200b2bb6ec",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0",
|
||||
|
||||
@@ -42,16 +42,16 @@ function makeErrorRecorder() {
|
||||
|
||||
// ─── 1. UNIT — dispatchHostCommand return values + pollution guard ───────────
|
||||
|
||||
describe('dispatchHostCommand: unit', () => {
|
||||
describe('dispatchHostCommand: unit', async () => {
|
||||
const CWD = '/fake/cwd';
|
||||
const RAW = false;
|
||||
|
||||
test('returns true for the migrated `state` command (consumed)', () => {
|
||||
test('returns true for the migrated `state` command (consumed)', async () => {
|
||||
// routeStateCommand will run against a fake cwd; it may call error() for a
|
||||
// missing subcommand — that is fine, we only assert the dispatch returned
|
||||
// true (consumed) rather than falling through to "Unknown command".
|
||||
const errFn = makeErrorRecorder();
|
||||
const consumed = dispatchHostCommand({
|
||||
const consumed = await dispatchHostCommand({
|
||||
command: 'state',
|
||||
args: ['state'],
|
||||
cwd: CWD,
|
||||
@@ -61,14 +61,18 @@ describe('dispatchHostCommand: unit', () => {
|
||||
assert.strictEqual(consumed, true, 'state must be consumed by the host table');
|
||||
});
|
||||
|
||||
test('all migrated Tier-1 host commands are consumed by the host table', () => {
|
||||
test('all migrated Tier-1 host commands are consumed by the host table', async () => {
|
||||
// Each migrated router receives its module-scope lib via the table entry;
|
||||
// against a fake cwd it may emit an error (missing subcommand), but the
|
||||
// dispatch itself must report consumed=true (no fall-through to the
|
||||
// unknown-command error).
|
||||
// NOTE: `capability` (P2) is omitted from this INVOCATION loop — it is async
|
||||
// and does FS/config reads, so invoking it against /fake/cwd is fragile.
|
||||
// It is covered by the registry-ownership assertion below (non-invoking)
|
||||
// and by the dedicated capability-lifecycle/consent/trust/state suites.
|
||||
for (const cmd of ['state', 'phase', 'init', 'roadmap', 'validate', 'verify']) {
|
||||
const errFn = makeErrorRecorder();
|
||||
const consumed = dispatchHostCommand({
|
||||
const consumed = await dispatchHostCommand({
|
||||
command: cmd,
|
||||
args: [cmd],
|
||||
cwd: CWD,
|
||||
@@ -79,9 +83,9 @@ describe('dispatchHostCommand: unit', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('returns false for an unknown command (fall through)', () => {
|
||||
test('returns false for an unknown command (fall through)', async () => {
|
||||
const errFn = makeErrorRecorder();
|
||||
const consumed = dispatchHostCommand({
|
||||
const consumed = await dispatchHostCommand({
|
||||
command: 'not-a-real-command',
|
||||
args: ['not-a-real-command'],
|
||||
cwd: CWD,
|
||||
@@ -92,10 +96,10 @@ describe('dispatchHostCommand: unit', () => {
|
||||
assert.strictEqual(errFn.calls.length, 0, 'error must not be called for a miss');
|
||||
});
|
||||
|
||||
test('prototype-pollution guard: __proto__/constructor/prototype fall through', () => {
|
||||
test('prototype-pollution guard: __proto__/constructor/prototype fall through', async () => {
|
||||
for (const bad of ['__proto__', 'constructor', 'prototype']) {
|
||||
const errFn = makeErrorRecorder();
|
||||
const consumed = dispatchHostCommand({
|
||||
const consumed = await dispatchHostCommand({
|
||||
command: bad,
|
||||
args: [bad],
|
||||
cwd: CWD,
|
||||
@@ -110,7 +114,7 @@ describe('dispatchHostCommand: unit', () => {
|
||||
|
||||
// ─── 2 + 3. DISPATCH + BEHAVIOR — end-to-end via runGsdTools ─────────────────
|
||||
|
||||
describe('state cutover: end-to-end dispatch via the host table', () => {
|
||||
describe('state cutover: end-to-end dispatch via the host table', async () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -121,7 +125,7 @@ describe('state cutover: end-to-end dispatch via the host table', () => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('`state load` succeeds end-to-end (dispatched via host table, not a case arm)', () => {
|
||||
test('`state load` succeeds end-to-end (dispatched via host table, not a case arm)', async () => {
|
||||
// Seed a minimal STATE.md so `state load` has something to read.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
@@ -136,7 +140,7 @@ describe('state cutover: end-to-end dispatch via the host table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown state subcommand surfaces the canonical unknown-subcommand error (non-zero exit)', () => {
|
||||
test('unknown state subcommand surfaces the canonical unknown-subcommand error (non-zero exit)', async () => {
|
||||
const result = runGsdTools(['--cwd=' + tmpDir, 'state', 'totally-not-a-subcommand'], process.cwd());
|
||||
assert.strictEqual(result.success, false, 'unknown subcommand must exit non-zero');
|
||||
assert.ok(
|
||||
@@ -148,9 +152,9 @@ describe('state cutover: end-to-end dispatch via the host table', () => {
|
||||
|
||||
// ─── 5. REGISTRY — HOST_COMMAND_ROUTERS owns `state` ────────────────────────
|
||||
|
||||
describe('HOST_COMMAND_ROUTERS registry', () => {
|
||||
test('owns all 6 migrated Tier-1 host commands as function entries', () => {
|
||||
for (const cmd of ['state', 'phase', 'init', 'roadmap', 'validate', 'verify']) {
|
||||
describe('HOST_COMMAND_ROUTERS registry', async () => {
|
||||
test('owns all 6 migrated Tier-1 host commands as function entries', async () => {
|
||||
for (const cmd of ['state', 'phase', 'init', 'roadmap', 'validate', 'verify', 'capability']) {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, cmd),
|
||||
`HOST_COMMAND_ROUTERS must own \`${cmd}\``,
|
||||
@@ -159,7 +163,7 @@ describe('HOST_COMMAND_ROUTERS registry', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('does NOT own prototype-pollution keys', () => {
|
||||
test('does NOT own prototype-pollution keys', async () => {
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(HOST_COMMAND_ROUTERS, '__proto__'),
|
||||
'HOST_COMMAND_ROUTERS must not own __proto__',
|
||||
|
||||
Reference in New Issue
Block a user