Files
msd-core/src/install-scope.cts
Tom Boucher 7a7bf19fc1 enhance(#2872): record scope and runtime in the install manifest (#3323)
* enhance(#2872): record scope and runtime in the install manifest

gsd-file-manifest.json gains manifestVersion, runtime and scope, and a new
read-only Installed Surface Resolver Module reads both install scopes for a
runtime in one call -- the first code path in the repo that does.

Phase 3 of epic #2866 (ADR-2866). Blocks Phase 4 (#2873), which resolves
#2218: the resolver's shadowedBy field is that defect expressed as a value
for the first time. It ships computed-and-unread here.

Installed-ness is decided by manifest PRESENCE, never by the new fields, so
a manifest written by an older GSD stays fully functional and no user needs
to reinstall. Recorded runtime/scope are corroboration: a disagreement with
the probed config dir is reported as declaredScopeMatchesProbe: false, never
silently corrected.

readInstallManifest is widened additively -- version/timestamp/mode/files
keep their exact names, types and meanings for all four existing callers.
manifestVersion is a new field rather than a reinterpretation of version,
which holds the package version and is read by the golden-parity fixtures.

Stems are derived from the installed manifest's own file keys, the inverse
of Phase 2's filename composition, guarded by a fast-check round-trip
property plus a kebab-case charset check so a crafted manifest key cannot
put a traversal segment, control character or ANSI escape into a trigger
that Phase 4 renders back to the user.

Also fixes two defects found while working:
- bin/install.js hardcoded manifestVersion: 2 while the reader owned
  MANIFEST_SCHEMA_VERSION = 2. Now single-sourced, with a parity test.
- docs/installer-migrations.md documented an install-state schema of five
  snake_case fields that have never been written; InstallState has only ever
  been { schemaVersion, appliedMigrations }. Corrected with a dated note.

Verification runs on the remote runner.

* fix(#2872): fold review findings from three independent engines

Standards axis:
- convert the manifest-schema suite from a hybrid setup(t) closure to
  beforeEach/afterEach (CONTRIBUTING.md:319-354 Pattern 1). The hybrid was
  neither approved pattern and a new test forgetting the call got no warning.
- SCOPE_ORDER was declared twice with no parity test -- this repo's recorded
  generative-fix-divergence class. Give the ordering one owner: install-scope
  exports it frozen, the layout module and the resolver both import it, and a
  test locks it against scopeRank so the constant and the ranks cannot drift.
- drop the defaultReadManifest passthrough (Middle Man).

Spec axis:
- add the VOLATILE_FILES exclusion test and source comment the acceptance
  table promised and did not deliver. gsd-file-manifest.json stays excluded:
  the new fields are deterministic, but timestamp -- the original reason --
  is unchanged.

Security axis:
- bound the reported manifest runtime at 64 chars, matching the
  truncatePostureValue convention already used in this subsystem. It reached
  declaredRuntime unbounded while the adjacent stems were gated by SAFE_STEM;
  an inconsistent posture on the same attacker-influenceable document. The
  charset stays ungated on purpose -- declaredRuntimeMatchesProbe needs to see
  the real value -- so Phase 4 must sanitize before rendering, recorded in the
  design's Known limits.

Both new parity tests were verified to FAIL when the two sides are made to
disagree, then pass again on revert. Verification runs on the remote runner.

* chore(#2872): backfill changeset pr number to 3323

* fix(#2872): give git fixture construction its own timeout class

PR #3323's full test (windows-latest, 22, shard 2/3) failed with

  gitOrThrow: 'git init' failed -- outcome=timed_out exitCode=null
  gitOrThrow: 'git commit --allow-empty' failed -- outcome=timed_out

from drift-detection.test.cjs's beforeEach, a file this branch never touched.
Every other lane passed the same commit, including windows-latest node 24 on
all three shards, and next is green.

Root cause is a bound sized for the wrong class. DEFAULT_GIT_TIMEOUT_MS is
15000 and its own comment scopes it to plumbing READS -- rev-parse, branch,
log -- against an existing repo. createFixture uses it for six sequential
repo-CONSTRUCTION spawns: init, three config writes, add -A, commit. init and
commit each write dozens of files, and on Windows every spawn is
Defender-scanned. Sibling tests in the failing block took 15.6-22.0s against
a 15000ms bound.

This repo already diagnosed this exact shape once: timeouts.cjs's
HOOK_FANOUT_TIMEOUT_MS records PR #3285 failing in the SAME job with the SAME
outcome=timed_out exitCode=null signature at the SAME bound while every other
lane passed, and concludes 'a bound sized for the wrong class, not a slow
machine'. It was fixed by splitting out a heavier class-norm at 60000. Same
remedy here: GIT_FIXTURE_TIMEOUT_MS = 60000, 4x the bound that failed and half
INSTALL_TIMEOUT_MS.

DEFAULT_GIT_TIMEOUT_MS deliberately stays at 15000 -- a blanket raise would
stop a genuinely hung plumbing read from surfacing fast.

Verified the value reaches the spawn rather than being an ignored option:
spawnSync was monkeypatched before requiring the fixture module, and all six
git construction calls were captured carrying timeout: 60000.

This branch's two new test files shift shard composition, which is how a
pre-existing fragility landed in the heaviest shard on the slowest lane.
Fixed here rather than deferred, per the no-defer rule.

Verification runs on the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-10 15:50:55 -04:00

362 lines
17 KiB
TypeScript

/**
* install-scope.cts — Install Scope Module (#2870, ADR-2866, governed by
* ADR-2866, Phase 0 PR #3265).
*
* `resolveScope()` turns a bare `'global' | 'local'` string — previously
* re-derived at 12 `isGlobal ? 'global' : 'local'` sites in `bin/install.js`
* plus several downstream consumers — into ONE resolved value produced by
* ONE module. See `.gsd/phase/feat-2870-install-scope-module/40-design.md`
* for the full behavior table and rationale; the summary that matters for
* future readers is captured in the comments below.
*
* This module OWNS the `InstallScope` type name. It was previously declared
* (as a private, non-exported `TypeAlias`) inside
* `runtime-artifact-install-plan.cts`; that module now imports it from here
* instead of re-declaring it, so the codebase has one spelling of "install
* scope" instead of a fifth one appearing alongside the three that already
* existed (`'local' | 'global'` in the layout module, `'global' | 'project'`
* in capability-lifecycle, and the single literal `'project'` in
* capability-consent).
*
* ── Compose, never modify, resolveConfigHomeFromDescriptor ─────────────────
* `resolveConfigHomeFromDescriptor` (`runtime-homes.cts`) is rated CRITICAL
* blast radius: 60 dependents across 13 files and 2 process flows. Adding a
* `scope` parameter to it — the "obvious" refactor — would touch all 60 for
* no reason this module needs: it already resolves the GLOBAL config home
* correctly today. So this module calls it as-is for the global scope and
* derives the LOCAL scope's config dir independently (see
* `resolveScopeConfigHome` below) — genuine composition, not a rename. Every
* one of those 60 call sites stays byte-identical.
*
* ── Why `settingsFile: null` is correct, not a bug ──────────────────────────
* Only `claude` declares `hostBehaviors.settingsFileByScope` in the
* capability registry; the other 18 registered runtimes do not have a
* per-scope settings file at all. Returning `null` for them is honest —
* substituting `'settings.json'` (or any other Claude-shaped default) would
* invent a fact for every non-Claude runtime that asked. Callers that
* legitimately want a Claude-specific fallback (there is exactly one today,
* `bin/install.js:550`) apply it themselves; this module does not.
*/
import path from 'node:path';
import os from 'node:os';
import {
resolveConfigHomeFromDescriptor,
type ConfigHomeDescriptor,
} from './runtime-homes.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
// ── Public types ────────────────────────────────────────────────────────
/**
* The two install-scope axis values. Spelling is `'local'`, not `'project'`:
* `'local'` is the CLI's own vocabulary (`--local`), matches what the
* artifact-layout module and the manifest already use, and is what the user
* types. `'project'` is a SEPARATE, deliberately un-unified vocabulary that
* belongs to the capability consent/lifecycle subsystem — see the boundary
* mapping below.
*/
export type InstallScope = 'global' | 'local';
export interface ResolvedScope {
id: InstallScope;
/** Absolute config directory for this scope, normalized to forward
* slashes (see `normalizeSeparators` below). */
configHome: string;
/** Per-scope settings filename declared by the runtime's descriptor, or
* `null` when the runtime declares none. `null` is a value, not an
* error — see the module-level comment above. */
settingsFile: string | null;
/** `false` for `global` (nothing is recorded — the scope lives under the
* user's own home, matching `capability-lifecycle.cts:163`'s rule).
* `true` for `local`. This module reports the requirement; it does not
* perform or waive consent — `capability-consent.cts` owns that. */
consentRequired: boolean;
/** Higher wins; `global` outranks `local`. Carried as data only — nothing
* in this phase reads it (Phase 2, #2871, is the first consumer). Not a
* shadowing/collision answer on its own: whether two artifacts actually
* collide needs the trigger, which is out of scope here. */
hostPrecedenceRank: number;
}
export interface ResolveScopeInput {
id: InstallScope;
runtime: string;
/** Explicit config-dir override (e.g. `--config-dir`). Honored identically
* to `getGlobalConfigDir(runtime, explicitDir)` — takes precedence over
* every other resolution path, for both scopes. */
explicitDir?: string;
env?: Record<string, string | undefined>;
home?: string;
existsSync?: (p: string) => boolean;
/** Working directory the `local` scope's `configHome` is resolved against
* — defaults to `process.cwd()`, so local-scope resolution is assertable
* without a real working directory, matching `env`/`home`/`existsSync`. */
cwd?: string;
}
// ── The `local` / `project` boundary (see CONTEXT.md glossary entry) ──────
//
// `ConsentRecord.scope: 'project'` (capability-consent.cts) and
// `capability-lifecycle.cts:163`'s `'global' | 'project'` are NOT renamed to
// match this module's `'local'` spelling. `ConsentRecord.scope` is persisted
// on disk in user-owned consent records outside this repo; renaming that
// literal would silently invalidate every existing project-scoped consent
// record on a user's machine the next time it is read back. The
// reconciliation is a documented boundary mapping, not a rename sweep:
// install scope 'local' ⇄ consent scope 'project'
// install scope 'global' ⇄ no consent record at all
// `consentRequired` above reports the install-scope side of that mapping;
// it is deliberately still the CLI's own vocabulary.
const VALID_SCOPE_IDS: ReadonlySet<string> = new Set(['global', 'local']);
/**
* Single owner of the `'global' | 'local'` membership check. `resolveScope`,
* `isGlobalScope`, `scopeRank`, and `resolveTriggerSurface`
* (`runtime-artifact-layout.cts`, #2871 Phase 2) all call this instead of
* each carrying its own copy of the rule — one validator every scope-typed
* seam reads, not N validators that could silently diverge. Exported so a
* sibling module can reuse it directly rather than re-deriving the same
* membership check a second time.
*/
export function validateScopeId(id: unknown, caller: string): InstallScope {
if (typeof id !== 'string' || !VALID_SCOPE_IDS.has(id)) {
throw new TypeError(
`${caller}: id must be one of 'global' | 'local', got ${JSON.stringify(id)}`,
);
}
return id as InstallScope;
}
/**
* Non-throwing sibling of {@link validateScopeId}, for readers that must
* report an unrecognized scope as a value rather than fail (#2872). Reads the
* same `VALID_SCOPE_IDS` set, so the two can never disagree about what a
* scope is.
*/
export function isInstallScopeId(value: unknown): value is InstallScope {
return typeof value === 'string' && VALID_SCOPE_IDS.has(value);
}
// Higher wins. Not exported as a public constant — only the resulting
// `hostPrecedenceRank` field on `ResolvedScope` is public API, so a future
// re-basing of the literal values (Phase 2, #2871) never requires touching
// an exported symbol.
const HOST_PRECEDENCE_RANK: Record<InstallScope, number> = {
global: 2,
local: 1,
};
interface HostBehaviorsForScope {
settingsFileByScope?: Partial<Record<InstallScope, string>>;
}
interface RuntimeDescriptorForScope {
configHome: ConfigHomeDescriptor;
/** Project-relative local config dir (e.g. `.claude`), or `null` for a
* runtime with no installable config dir at all (vscode). Resolved
* against `resolveScope`'s input `cwd` (defaulting to the real process
* cwd), the same way `bin/install.js`'s existing local-scope call sites
* (e.g. `path.join(process.cwd(), '.agents')`) resolve it today. */
localConfigDir?: string | null;
hostBehaviors?: HostBehaviorsForScope;
}
interface RegistryLike {
runtimes: Record<string, { runtime?: RuntimeDescriptorForScope }>;
}
/** Lazy registry accessor — mirrors the pattern in runtime-homes.cts /
* runtime-artifact-layout.cts (5b/5c/5d). */
function getRegistry(): RegistryLike {
return _require('./capability-registry.cjs') as RegistryLike;
}
/**
* Normalize path separators UNCONDITIONALLY (never gated on `path.sep` /
* `process.platform`). A Windows-shaped `home` (`C:\Users\x`) can arrive on
* any host — via an injected test fixture, a cross-platform config sync, or
* a value copied from a Windows machine — so the normalization must not
* depend on which OS this process happens to be running on.
*/
function normalizeSeparators(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Minimal leading-`~` expansion for `explicitDir`. `runtime-homes.cts`'s own
* `expandTilde` is NOT exported (it is a private helper), and this module
* must not add exports to that CRITICAL-blast-radius file just to reuse
* three lines — so this is an intentionally small, independent
* reimplementation, not a fork of shared logic.
*/
function expandTildeForExplicitDir(p: string, home: string | undefined): string {
const resolvedHome = home ?? os.homedir();
if (p === '~') return resolvedHome;
if (p.startsWith('~/')) return path.join(resolvedHome, p.slice(2));
return p;
}
/**
* Resolve the config-home directory for one scope. `explicitDir` short-
* circuits both scopes identically (matches `getGlobalConfigDir`'s existing
* override behavior — the module must not regress it). Otherwise:
* - `global`: delegates entirely to `resolveConfigHomeFromDescriptor`
* (composition — see the module-level comment).
* - `local`: joins the registry's `localConfigDir` onto `cwd` (defaulting
* to the real process cwd) — the project-local dir, independent of
* `home`/`env`.
*/
function resolveScopeConfigHome(
id: InstallScope,
descriptor: RuntimeDescriptorForScope,
input: ResolveScopeInput,
): string {
const explicitDir = input.explicitDir;
if (typeof explicitDir === 'string' && explicitDir.trim() !== '') {
return normalizeSeparators(expandTildeForExplicitDir(explicitDir, input.home));
}
if (id === 'local') {
// localConfigDir is guaranteed non-null here: the only registered
// runtime with `localConfigDir: null` is vscode, and vscode's
// `configHome.kind === 'none'` already causes resolveScope to throw
// before this function is ever called (see the 'none' guard below).
const localConfigDir = descriptor.localConfigDir as string;
const cwd = input.cwd ?? process.cwd();
return normalizeSeparators(path.join(cwd, localConfigDir));
}
return normalizeSeparators(
resolveConfigHomeFromDescriptor(descriptor.configHome, {
env: input.env,
home: input.home,
existsSync: input.existsSync,
}),
);
}
/**
* Resolve a bare `'global' | 'local'` scope id plus a runtime into a single
* `ResolvedScope` value: the config directory, the per-scope settings
* filename (or `null`), whether the scope requires a consent record, and a
* precedence rank (data only this phase — see `hostPrecedenceRank` above).
*
* Pure: performs no writes and no I/O of its own beyond what
* `resolveConfigHomeFromDescriptor` already performs via the injected
* `existsSync` (for `global`) or the injected `cwd`, defaulting to
* `process.cwd()` (for `local`). Never mutates `input`. The returned object
* is frozen so a caller mutating the result cannot corrupt a subsequent
* call.
*
* Throws `TypeError` for:
* - an `id` outside `'global' | 'local'` — including wrong case, empty,
* missing, or any non-string value (no coercion, ever);
* - an unknown `runtime` (no matching capability-registry entry);
* - a `runtime` whose descriptor has `configHome.kind === 'none'`
* (vscode) — there is no installable config directory to resolve, so
* inventing one (or silently returning `configHome: null`) would be
* dishonest. All three cases share one catch shape (`instanceof
* TypeError`) with `resolveRuntimeArtifactLayout`'s existing contract
* for unknown runtimes, so callers of both never need two different
* catch blocks.
*/
export function resolveScope(input: ResolveScopeInput): ResolvedScope {
const scopeId = validateScopeId(input?.id, 'resolveScope');
const runtime = input.runtime;
const registryEntry = typeof runtime === 'string'
? getRegistry().runtimes[runtime]
: undefined;
const descriptor = registryEntry?.runtime;
if (!descriptor) {
throw new TypeError(
`resolveScope: unknown runtime '${String(runtime)}' — not present in the capability registry`,
);
}
if (descriptor.configHome.kind === 'none') {
// #2103: vscode-shaped runtimes (Marketplace/VSIX, installSurface:
// 'none') have no file-projected config directory at all — the same
// carve-out tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES
// already documents. Throwing here matches
// resolveConfigHomeFromDescriptor's own deliberate throw on this kind,
// rather than silently inventing an install scope for a runtime that
// cannot be installed.
throw new TypeError(
`resolveScope: runtime '${runtime}' has no installable config directory (configHome.kind === 'none')`,
);
}
const configHome = resolveScopeConfigHome(scopeId, descriptor, input);
const settingsFile = descriptor.hostBehaviors?.settingsFileByScope?.[scopeId] ?? null;
const consentRequired = scopeId === 'local';
const hostPrecedenceRank = HOST_PRECEDENCE_RANK[scopeId];
return Object.freeze({
id: scopeId,
configHome,
settingsFile,
consentRequired,
hostPrecedenceRank,
});
}
/**
* Project an `InstallScope` down to the boolean shape some downstream APIs
* still require. Four call sites (both kind-builder closures in
* `runtime-artifact-layout.cts`, plus one each in
* `runtime-artifact-install-plan.cts` and `surface.cts`) were each
* independently re-deriving this same `scope === 'global'` comparison — four
* copies of one rule that could silently drift apart (#2870). They exist
* because `runtime-artifact-conversion.cts`'s `_computePathPrefix` takes
* `isGlobal: boolean` at its API boundary, and that boundary is not changing
* here, so the boolean projection cannot be eliminated — only centralized to
* the one place below.
*
* Throws the same `TypeError`, with the same message shape, as
* `resolveScope` throws for an `id` outside `'global' | 'local'` — both call
* `validateScopeId` above, so the two error contracts cannot diverge.
*
* Deliberately throws, rather than returning `false`, for an out-of-union
* value — unlike the inline `scope === 'global'` comparison it replaced,
* which silently returned `false` for anything unrecognized. The
* alternative is silently treating an unknown scope as "not global" and
* writing artifacts to the wrong place, which is worse than failing loud.
* A caller holding an optional `scope` (e.g. a raw `Layout.scope`) must
* default it before calling this — see `surface.cts` for the pattern.
*/
export function isGlobalScope(scope: InstallScope): boolean {
return validateScopeId(scope, 'isGlobalScope') === 'global';
}
/**
* Project a bare `InstallScope` down to its `hostPrecedenceRank` — the SAME
* `HOST_PRECEDENCE_RANK` table `resolveScope`'s `ResolvedScope.hostPrecedenceRank`
* field reads, exposed standalone so a caller that only needs the ranking (not a
* full config-home resolution, which touches the filesystem via
* `resolveConfigHomeFromDescriptor`) never has to re-derive `{global: 2, local:
* 1}` as a second copy of the same fact. First consumer: `resolveTriggerSurface`
* (`runtime-artifact-layout.cts`, #2871 Phase 2), which is documented pure — no
* filesystem — so it cannot call `resolveScope` itself. Same validation/error
* contract as `resolveScope` / `isGlobalScope`: all three share `validateScopeId`,
* so an out-of-union `id` throws the same `TypeError` shape everywhere.
*/
export function scopeRank(id: InstallScope): number {
return HOST_PRECEDENCE_RANK[validateScopeId(id, 'scopeRank')];
}
/**
* Both scope ids, highest host precedence first. The ONE ordering of the
* install-scope axis: `runtime-artifact-layout.cts`'s trigger resolution and
* `installed-surface-resolver.cts`'s scope-record construction both consume
* this rather than each re-declaring `['global','local']` (#2872 review
* finding — this repo's recorded "generative fix divergence" class). Frozen so
* a caller cannot reorder it for everyone else. Ordering is not arbitrary: it
* is `scopeRank` descending, and a test locks that so the two cannot drift.
*/
export const SCOPE_ORDER: readonly InstallScope[] = Object.freeze(['global', 'local']);