* 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>
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
/**
|
|
* Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters).
|
|
*
|
|
* Owns:
|
|
* - getAgentsDir(runtime?, projectRoot?): string
|
|
* - checkAgentsInstalled(runtime?, projectRoot?): AgentsInstalledResult
|
|
*
|
|
* The core.cjs re-export spine was retired in epic #1267; callers import
|
|
* these symbols from agent-install-check.cjs directly.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import modelProfiles = require('./model-profiles.cjs');
|
|
const { MODEL_PROFILES } = modelProfiles;
|
|
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
|
import { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } from './runtime-name-policy.cjs';
|
|
// #3242 — model-catalog is a genuine leaf (only node:path + its own JSON), which is
|
|
// exactly why Phase 1 (#3241) moved isAnthropicFlavoredModel there: this module can
|
|
// consume it without dragging model-resolver's config-loader chain into a
|
|
// pure read/verify surface.
|
|
import { isAnthropicFlavoredModel } from './model-catalog.cjs';
|
|
// #3243 — the Codex `.toml` block-range/BOM/scan primitives moved into the typed
|
|
// IR module (Phase 3), which this reader now imports rather than defining
|
|
// locally. Behavior is unchanged: scanTomlLines/stripBOM here are the exact
|
|
// same lenient functions that used to live in this file — see
|
|
// codex-agent-toml.cts's module header for the reader/writer reconciliation.
|
|
import { stripBOM, scanTomlLines } from './codex-agent-toml.cjs';
|
|
|
|
interface AgentsInstalledResult {
|
|
agents_installed: boolean;
|
|
missing_agents: string[];
|
|
installed_agents: string[];
|
|
incomplete_agents: string[];
|
|
agents_dir: string;
|
|
agent_runtime: string;
|
|
}
|
|
|
|
/**
|
|
* Frozen reason enum for {@link checkCodexModelPosture}. Per CONTRIBUTING's
|
|
* typed-IR rule ("Error / status / reason → a frozen enum"): callers and tests
|
|
* assert on these wire values, never on prose. Adding a member is a deliberate
|
|
* three-way coordinated change — enum, emitting site, and the enum-lock test.
|
|
*/
|
|
const POSTURE_REASON = Object.freeze({
|
|
ANTHROPIC_FLAVORED_MODEL: 'anthropic_flavored_model',
|
|
ORPHANED_REASONING_EFFORT: 'orphaned_reasoning_effort',
|
|
UNREADABLE: 'unreadable',
|
|
NOT_CODEX: 'not_codex',
|
|
AGENTS_DIR_MISSING: 'agents_dir_missing',
|
|
});
|
|
|
|
type PostureReason = (typeof POSTURE_REASON)[keyof typeof POSTURE_REASON];
|
|
|
|
interface PostureViolation {
|
|
agent: string;
|
|
file: string;
|
|
reason: PostureReason;
|
|
value?: string;
|
|
}
|
|
|
|
interface CodexModelPostureResult {
|
|
ok: boolean;
|
|
violations: PostureViolation[];
|
|
checked: string[];
|
|
agents_dir: string;
|
|
agent_runtime: string;
|
|
reason?: PostureReason;
|
|
}
|
|
|
|
// Matches the value-truncation convention in bin/install.js's
|
|
// _warnCodexModelOverrideDropped: values over 64 chars are capped so an
|
|
// oversized or secret-shaped config value can never reach a report in full.
|
|
function truncatePostureValue(value: string): string {
|
|
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
|
}
|
|
|
|
/**
|
|
* Resolve the agents directory for the given runtime.
|
|
*
|
|
* Priority:
|
|
* 1. GSD_AGENTS_DIR env var (explicit override, any runtime)
|
|
* 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
|
|
* This is correct for both repo runs and real installs (the runtime config dir's
|
|
* agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
|
|
* 3. For non-claude runtimes with a manifest-backed project-local install:
|
|
* <projectRoot>/<localConfigDir>/agents (or <projectRoot>/agents when
|
|
* the runtime's local install targets the project root). Requiring the
|
|
* GSD manifest prevents runtime-native project agents from shadowing a
|
|
* working global GSD install. Symlinked local agent directories are ignored.
|
|
* 4. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
|
|
*
|
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
* @param projectRoot - canonical project root for local-install discovery
|
|
*/
|
|
function getAgentsDir(runtime?: string, projectRoot?: string): string {
|
|
if (process.env['GSD_AGENTS_DIR']) {
|
|
return process.env['GSD_AGENTS_DIR'];
|
|
}
|
|
const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
if (resolved === 'claude') {
|
|
return path.join(__dirname, '..', '..', '..', 'agents');
|
|
}
|
|
if (projectRoot) {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { runtimes } = require('./capability-registry.cjs') as {
|
|
runtimes: Record<string, { runtime?: { hostBehaviors?: { localTargetIsProjectRoot?: boolean } } }>;
|
|
};
|
|
const runtimeConfig = runtimes[resolved]?.runtime;
|
|
const localConfigDirName = getDirName(resolved);
|
|
const localConfigDir = localConfigDirName === NO_LOCAL_CONFIG_DIR_SENTINEL
|
|
? undefined
|
|
: runtimeConfig?.hostBehaviors?.localTargetIsProjectRoot
|
|
? projectRoot
|
|
: path.join(projectRoot, localConfigDirName);
|
|
if (!localConfigDir) {
|
|
return path.join(getGlobalConfigDir(resolved), 'agents');
|
|
}
|
|
const localAgentsDir = path.join(localConfigDir, 'agents');
|
|
const manifestPath = path.join(localConfigDir, 'gsd-file-manifest.json');
|
|
try {
|
|
if (fs.lstatSync(localAgentsDir).isDirectory() && fs.lstatSync(manifestPath).isFile()) {
|
|
return localAgentsDir;
|
|
}
|
|
} catch {
|
|
// Local discovery is best-effort; any probe failure preserves global fallback.
|
|
}
|
|
}
|
|
return path.join(getGlobalConfigDir(resolved), 'agents');
|
|
}
|
|
|
|
/**
|
|
* Check which GSD agents are installed on disk.
|
|
*
|
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
* @param projectRoot - canonical project root for local-install discovery
|
|
*/
|
|
function checkAgentsInstalled(runtime?: string, projectRoot?: string): AgentsInstalledResult {
|
|
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
const agentsDir = getAgentsDir(resolvedRuntime, projectRoot);
|
|
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
const installed: string[] = [];
|
|
const missing: string[] = [];
|
|
|
|
if (!fs.existsSync(agentsDir)) {
|
|
return {
|
|
agents_installed: false,
|
|
missing_agents: expectedAgents,
|
|
installed_agents: [],
|
|
incomplete_agents: [],
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
};
|
|
}
|
|
|
|
for (const agent of expectedAgents) {
|
|
const agentFile = path.join(agentsDir, `${agent}.md`);
|
|
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
|
|
const agentFileCodex = path.join(agentsDir, `${agent}.toml`);
|
|
const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`);
|
|
const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`);
|
|
const kimiAgentInstalled =
|
|
resolvedRuntime === 'kimi' &&
|
|
fs.existsSync(agentFileKimiYaml) &&
|
|
fs.existsSync(agentFileKimiPrompt);
|
|
if (
|
|
fs.existsSync(agentFile) ||
|
|
fs.existsSync(agentFileCopilot) ||
|
|
fs.existsSync(agentFileCodex) ||
|
|
kimiAgentInstalled
|
|
) {
|
|
installed.push(agent);
|
|
} else {
|
|
missing.push(agent);
|
|
}
|
|
}
|
|
|
|
// ── Manifest-backed completeness check ──────────────────────────────────────
|
|
// If a gsd-file-manifest.json exists alongside the agents dir (parent dir),
|
|
// verify that every manifest-tracked file for each expected agent is present
|
|
// on disk. Missing manifest-tracked files indicate an incomplete install even
|
|
// when the plain presence check above passed (e.g. .md present, .toml absent).
|
|
// If no manifest is found the check is a no-op (graceful for claude/bundled).
|
|
const incomplete: string[] = [];
|
|
// #2872: the manifest read is the Installer Migration Module's, not a
|
|
// fourth private copy of it. Lazily required — matching this file's own
|
|
// capability-registry idiom — so a pure read/verify surface on the
|
|
// init/verify hot path takes no new static dependency.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { readInstallManifest } = require('./installer-migrations.cjs') as {
|
|
readInstallManifest: (configDir: string) => { files: Record<string, string> };
|
|
};
|
|
const manifestFiles: Record<string, unknown> = readInstallManifest(path.dirname(agentsDir)).files;
|
|
|
|
if (Object.keys(manifestFiles).length > 0) {
|
|
for (const agent of expectedAgents) {
|
|
// Find all manifest keys that belong to this agent:
|
|
// key must be "agents/<agentName>.<ext>" with no further path segments.
|
|
const agentPrefix = `agents/${agent}.`;
|
|
const agentManifestKeys = Object.keys(manifestFiles).filter(key => {
|
|
if (!key.startsWith(agentPrefix)) return false;
|
|
const rest = key.slice(agentPrefix.length);
|
|
// rest must be a bare extension (no slashes, non-empty)
|
|
return rest.length > 0 && !rest.includes('/');
|
|
});
|
|
if (agentManifestKeys.length === 0) {
|
|
// Agent not tracked in manifest — skip completeness check for this agent
|
|
continue;
|
|
}
|
|
const allPresent = agentManifestKeys.every(key => {
|
|
const basename = key.slice('agents/'.length);
|
|
return fs.existsSync(path.join(agentsDir, basename));
|
|
});
|
|
if (!allPresent) {
|
|
incomplete.push(agent);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0,
|
|
missing_agents: missing,
|
|
installed_agents: installed,
|
|
incomplete_agents: incomplete,
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate Codex `.toml` agent files for Anthropic-flavored `model` pins and
|
|
* orphaned `model_reasoning_effort` values (ADR-2313 D6, #3242).
|
|
*
|
|
* A new sibling export to {@link checkAgentsInstalled}, deliberately — that
|
|
* function carries 33 upstream dependents and cyclomatic complexity 25, so this
|
|
* posture check gets zero new branches there (see 40-design.md "Rejected" #1).
|
|
* Presence is `checkAgentsInstalled`'s job; this function's job starts only once
|
|
* the runtime is confirmed `codex` and only inspects posture, never presence.
|
|
*
|
|
* Read-only: detects, never repairs (repair is Phase 3, #3243).
|
|
*
|
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
* @param projectRoot - canonical project root for local-install discovery
|
|
*/
|
|
function checkCodexModelPosture(runtime?: string, projectRoot?: string): CodexModelPostureResult {
|
|
// Short-circuit BEFORE any filesystem access: a non-codex runtime must never
|
|
// have its agents directory resolved or a stray .toml inspected, however
|
|
// violating that file's contents would be if it were ever read (#3242 row 25).
|
|
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
if (resolvedRuntime !== 'codex') {
|
|
return {
|
|
ok: true,
|
|
violations: [],
|
|
checked: [],
|
|
agents_dir: '',
|
|
agent_runtime: resolvedRuntime,
|
|
reason: POSTURE_REASON.NOT_CODEX,
|
|
};
|
|
}
|
|
|
|
const agentsDir = getAgentsDir(resolvedRuntime, projectRoot);
|
|
if (!fs.existsSync(agentsDir)) {
|
|
// Presence is checkAgentsInstalled's job — an absent agents dir here is a
|
|
// distinct, non-violating outcome, not a failure of this check.
|
|
return {
|
|
ok: true,
|
|
violations: [],
|
|
checked: [],
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
reason: POSTURE_REASON.AGENTS_DIR_MISSING,
|
|
};
|
|
}
|
|
|
|
// Skip symlinks — matches cmdEffortSync's existing idiom in commands.cts
|
|
// ("Skip symlinks — only write regular files..."). Here the risk is reading
|
|
// (not writing) through a symlink: readFileSync follows symlinks, so an
|
|
// agents-dir symlink pointing at an arbitrary file would let that target's
|
|
// contents be echoed into this function's `value` field. A symlinked agent
|
|
// file is a structural install choice (checkAgentsInstalled's territory),
|
|
// not a model-content posture defect, so it is silently excluded from
|
|
// `checked` rather than reported as a distinct violation — same shape as
|
|
// cmdEffortSync, which silently drops symlinks from its file list rather
|
|
// than inventing a new skip/violation reason.
|
|
const tomlFiles = fs
|
|
.readdirSync(agentsDir)
|
|
.filter((entry) => {
|
|
if (!entry.endsWith('.toml')) return false;
|
|
try {
|
|
return fs.lstatSync(path.join(agentsDir, entry)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
.sort();
|
|
|
|
const checked: string[] = [];
|
|
const violations: PostureViolation[] = [];
|
|
|
|
for (const entry of tomlFiles) {
|
|
const agentName = entry.slice(0, -'.toml'.length);
|
|
const filePath = path.join(agentsDir, entry);
|
|
checked.push(agentName);
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = fs.readFileSync(filePath, 'utf8');
|
|
} catch {
|
|
// Never throws, never silently skips — an unreadable file is reported and
|
|
// the loop continues checking the rest (40-design.md "Rejected" #5).
|
|
violations.push({ agent: agentName, file: filePath, reason: POSTURE_REASON.UNREADABLE });
|
|
continue;
|
|
}
|
|
|
|
const { model, hasReasoningEffort } = scanTomlLines(stripBOM(raw));
|
|
|
|
if (model !== null && isAnthropicFlavoredModel(model)) {
|
|
violations.push({
|
|
agent: agentName,
|
|
file: filePath,
|
|
reason: POSTURE_REASON.ANTHROPIC_FLAVORED_MODEL,
|
|
value: truncatePostureValue(model),
|
|
});
|
|
} else if (model === null && hasReasoningEffort) {
|
|
// #838 coupling: a reasoning-effort pin with no model pin means Codex
|
|
// inherits the session model while the effort pin silently disagrees.
|
|
violations.push({
|
|
agent: agentName,
|
|
file: filePath,
|
|
reason: POSTURE_REASON.ORPHANED_REASONING_EFFORT,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: violations.length === 0,
|
|
violations,
|
|
checked,
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
};
|
|
}
|
|
|
|
export = {
|
|
getAgentsDir,
|
|
checkAgentsInstalled,
|
|
checkCodexModelPosture,
|
|
POSTURE_REASON,
|
|
};
|