diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index b3ba5789a..d03bc98cc 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 7bc4c1c05..5a90a679e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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)} `; 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 | diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index ca074a116..49c397aa6 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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 ] - // 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/ 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 (#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 [--on|--off|--enable|--disable] [--gate =]... [--config-dir ] [--runtime ] [--scope ] - const capId = args[2]; - if (!capId || capId.startsWith('--')) { - error('Missing capability id for: capability set ', 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 = (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 =)', ERROR_REASON ? ERROR_REASON.USAGE : undefined); - } - const eqIdx = gateVal.indexOf('='); - if (eqIdx === -1) { - error(`Malformed --gate value "${gateVal}": expected =`, 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 [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file ]… - const spec = args[2]; - if (!spec || spec.startsWith('--')) { - error('Missing for: capability install ', 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 [ | --all] [--scope global|project] [--yes] [--shared-file ]… - const all = capHasFlag('--all'); - const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined; - if (!all && !id) { - error('capability update requires or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined); - } - if (all && id) { - error('capability update: pass either 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 () - // 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 [--purge-data] [--scope global|project] - const id = args[2]; - if (!id || id.startsWith('--')) { - error('Missing for: capability remove ', 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 ` 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 — toggles activation state (same mechanism as: capability set --off|--on). - const id = args[2]; - if (!id || id.startsWith('--')) { - error(`Missing for: capability ${capSubcommand} `, 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 [--project ] - // 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 for: capability trust revoke ', 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 diff --git a/gsd-core/bin/lib/capability-command-router.cjs b/gsd-core/bin/lib/capability-command-router.cjs new file mode 100644 index 000000000..a50290def --- /dev/null +++ b/gsd-core/bin/lib/capability-command-router.cjs @@ -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 ] + // 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/ 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 (#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 [--on|--off|--enable|--disable] [--gate =]... [--config-dir ] [--runtime ] [--scope ] + const capId = args[2]; + if (!capId || capId.startsWith('--')) { + error('Missing capability id for: capability set ', 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 = (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 =)', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + const eqIdx = gateVal.indexOf('='); + if (eqIdx === -1) { + error(`Malformed --gate value "${gateVal}": expected =`, 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 [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file ]… + const spec = args[2]; + if (!spec || spec.startsWith('--')) { + error('Missing for: capability install ', 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 [ | --all] [--scope global|project] [--yes] [--shared-file ]… + const all = capHasFlag('--all'); + const id = args[2] && !args[2].startsWith('--') ? args[2] : undefined; + if (!all && !id) { + error('capability update requires or --all', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + if (all && id) { + error('capability update: pass either 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 () + // 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 [--purge-data] [--scope global|project] + const id = args[2]; + if (!id || id.startsWith('--')) { + error('Missing for: capability remove ', 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 ` 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 — toggles activation state (same mechanism as: capability set --off|--on). + const id = args[2]; + if (!id || id.startsWith('--')) { + error(`Missing for: capability ${capSubcommand} `, 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 [--project ] + // 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 for: capability trust revoke ', 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 }; diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 86c506715..726d6185b 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 39f68cdb7..0f8d8ba50 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index ce602e43f..3ab9268b9 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index fadf891aa..35b1bbd21 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 141078da1..85a84bb71 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 1e76d2ee3..495457431 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 6dd0e8b79..6403b27dc 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index c3f5b7c6b..0921d6cb4 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index b05e46daa..d2b540eb0 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 12b146cf0..8955f3477 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 20412ceb4..5690e9054 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 181982a4b..887ae42b6 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index b77d1513c..369592b74 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index a2b0459a3..cd7fd0e54 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 06bf21cd3..acd3549a2 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index cd0b27116..81c8d0b25 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index d05197991..993ac9591 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index 4157821cd..739e11b42 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -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", diff --git a/tests/state-command-cutover.test.cjs b/tests/state-command-cutover.test.cjs index 711e4c2bc..2eec073ac 100644 --- a/tests/state-command-cutover.test.cjs +++ b/tests/state-command-cutover.test.cjs @@ -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__',