diff --git a/.changeset/clever-tigers-dart.md b/.changeset/clever-tigers-dart.md new file mode 100644 index 000000000..4a99171dd --- /dev/null +++ b/.changeset/clever-tigers-dart.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3600 +--- +**User profile and dev-preferences files are no longer lost when an install or uninstall is interrupted.** These files were held only in memory while GSD deleted and rebuilt the directory containing them, so pressing Ctrl-C — or any crash during the copy — destroyed them permanently. On the main install path that window spanned the entire gsd-core tree rebuild. They are now staged to disk before anything is deleted, and any copy orphaned by an interrupted run is restored automatically on the next install or uninstall. (#1874) diff --git a/.changeset/zesty-rams-march.md b/.changeset/zesty-rams-march.md new file mode 100644 index 000000000..93af49d80 --- /dev/null +++ b/.changeset/zesty-rams-march.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3600 +--- +**Agent files now install identically whether you run a full install or apply a surface.** Every runtime materializes its agents from its capability descriptor, so `/gsd-surface --materialize` no longer skips agent files for Cline, Codex, Hermes, Kilo, OpenCode and Kimi Code — previously it wrote none for those runtimes, leaving an install missing the agents a fresh install would have created. Installed output is byte-identical to before for every runtime. (#2866) diff --git a/.gitignore b/.gitignore index e277d4d93..2adb1412b 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ build/ /gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs /gsd-core/bin/lib/handshake-serialized.cjs /gsd-core/bin/lib/install-effort-resolver.cjs +/gsd-core/bin/lib/install-model-override-resolver.cjs /gsd-core/bin/lib/install-engine.cjs /gsd-core/bin/lib/embedding-adapter.cjs /gsd-core/bin/lib/adapter-declarative.cjs @@ -219,6 +220,7 @@ build/ /gsd-core/bin/lib/runtime-artifact-layout.cjs /gsd-core/bin/lib/install-scope.cjs /gsd-core/bin/lib/install-fs-adapter.cjs +/gsd-core/bin/lib/user-artifact-staging.cjs /gsd-core/bin/lib/installed-surface-resolver.cjs /gsd-core/bin/lib/install-shadow-report.cjs /gsd-core/bin/lib/runtime-config-adapter-registry.cjs diff --git a/CONTEXT.md b/CONTEXT.md index ccdc99b12..62f9769f8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -230,6 +230,9 @@ Leaf module owning the **static** model tables and the closed vocabularies deriv ### Model Resolver Module Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (`resolveModelInternal`, `resolveModelPolicy`, `resolveTierEntry`, `resolveModelForTier`, `resolveGranularityInternal`, `resolveEffortInternal`, `resolveFastModeInternal`, `resolveEffortForTier`, `nextEffort`, `assertValidGranularityOverride`). Depends only on leaf modules (`config-loader` for `loadConfig`, `configuration` for defaults, `model-profiles` and `model-catalog` for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. **`CLAUDE_AGENT_ALIASES` no longer lives here** — it moved down to the Model Catalog Module (#3241, ADR-2313 Phase 1) so the Agent Install Check and Codex-sync surfaces can consume the alias rule without taking a `config-loader` dependency this module would have dragged with it; it is still **re-exported** from here, so existing importers (`bin/install.js`, `tests/codex-config.test.cjs`) are unaffected and a parity test asserts both modules expose the same set. Source of truth: `gsd-core/bin/lib/model-resolver.cjs` (generated from `src/model-resolver.cts`). +### Install Model Override Resolver Module +Module owning install-time per-agent model-override resolution (#2256 / #2794), extracted from `bin/install.js`'s inline agent-staging loop (#2875 Part 2 / J8), which duplicated this EXACT precedence chain across two runtime branches (OpenCode/Kilo, ~24 lines each): `model_overrides[agent]` > `model_profile_overrides..` > omit. Interface: `readGsdGlobalModelOverrides`/`readGsdEffectiveModelOverrides`/`readGsdRuntimeProfileResolver` (impure config-file reads, mirroring `install-effort-resolver.cts`'s existing extraction precedent) and `resolveAgentModelOverride` (pure given their pre-resolved outputs). Reached from `runtime-artifact-layout.cts`'s agents-kind `stage()` for the opencode/kilo converters, so it is on the `installRuntimeArtifacts` call tree — every fs touch routes through `installFs()` (Install Fs Adapter Module) rather than calling `node:fs` directly, so a fake adapter injected via `withInstallFs` is honored. A single source of truth means the descriptor-driven agents pipeline and `bin/install.js`'s own callers can only diverge if this module changes, not silently across two hand-maintained copies (the Generative Fix Divergence class CLAUDE.md's "Known Defects" section warns about). Source: `src/install-model-override-resolver.cts` -> `gsd-core/bin/lib/install-model-override-resolver.cjs`. See Install Engine Module, Model Resolver Module. + ### Codex Agent TOML Module A **genuine leaf** (node builtins only) owning the typed IR for `~/.codex/agents/.toml` (#3243, ADR-2313 Phase 3). It is a **document model, not a policy** — it knows how to parse/render/strip the two keys the posture owns (`model`, `model_reasoning_effort`); it does NOT know which `model` values are illegal for Codex (that predicate, `isAnthropicFlavoredModel`, stays in the Model Catalog Module and the caller decides what to strip). `parseCodexAgentToml(content) → {ok:true,doc} | {ok:false,reason}` is the STRICT half: `PARSE_REASON.UNTERMINATED_BLOCK` when the `developer_instructions` block is opened but never closed, because a writer that proceeds on a malformed document risks rewriting it. `renderCodexAgentToml(doc)` round-trips **byte-identically** for an unmodified doc — the load-bearing property that stops a sync from silently reformatting a user's file — by keeping the original `lines` array (never re-derived) plus the detected `eol`/BOM/trailing-newline metadata, and rejoining rather than reconstructing. `stripModel`/`stripReasoningEffort` remove exactly one targeted line, re-indexing the block range and the sibling key's line index; every other line (comments, hand-added keys, the prompt block, line endings) is untouched. `scanTomlLines`/`stripBOM`/`findDeveloperInstructionsBlockRange`/`unquoteTomlValue` are the LENIENT reader primitives — moved here (not copied) from the Agent Install Check Module (#3242, Phase 2), which still imports and calls them directly, unchanged in behavior: an unterminated block falls back to "rest of file is inside the block" rather than failing, because misreading prompt prose as a pin is only a false positive. One block-range detector (`findDeveloperInstructionsBlockRange`, now carrying a `terminated` flag the strict parser reads and the lenient scanner ignores) serves both policies, so the reader and the writer can never silently diverge on where the block ends. Consumed by the Codex `.toml` sync (Commands Module's `cmdEffortSyncCodex`, ADR-2313 D7) and the Agent Install Check Module's `checkCodexModelPosture`. Source of truth: `gsd-core/bin/lib/codex-agent-toml.cjs` (generated from `src/codex-agent-toml.cts`). See Agent Install Check Module, Model Catalog Module, ADR-2313. @@ -264,7 +267,10 @@ Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module. ### Install Fs Adapter Module -Narrow, enumerated fs seam for the `installRuntimeArtifacts` call tree (`src/install-engine.cts`) — lands ADR-58's never-shipped `cleanup` rollout step (`registry → adapter → helpers → cleanup`, #2874, epic #2866 Phase 5). `installRuntimeArtifacts` now returns the executed plan it ran (`{ runtime, scope, kinds: [{kind, sourceDir, destDir, preserved}], cleanup: [{dir, ok}], postSteps }`) instead of `void`, including on the `combinedFamilyInstall` (OpenCode/Kilo) early-return path — no path may return `undefined` after this phase. Failure is unchanged: stage/rewrite errors still throw rather than becoming an `ok:false` value, so a caller cannot read success-shaped data off a failure path. Delivery is an ambient single mutable adapter (`current`), swapped for the duration of one synchronous install via `withInstallFs(deps.fs, fn)` and always restored in a `finally` — a `deps` parameter threaded through every function on the call tree (`install-profiles.cts`, `runtime-artifact-conversion.cts`, `commonjs-marker.cts`, `installer-migrations.cts`'s two reachable entry points) was rejected as a dozen+-site touch for no behavioral gain over the ambient swap, extending rather than replacing `createRuntimeArtifactInstallPlan`'s existing `deps` bag precedent (Runtime Artifact Install Plan Module). An injected `deps.fs` is a PARTIAL adapter merged over real `node:fs`; any method it omits silently resolves to the real filesystem. **Routes destination IO only, by design**: every write/probe against the install destination (copies, removals, snapshot/restore of preserved skill dirs, the manifest read) is fake-able; locating this package's own source tree (`findInstallSourceRoot`/`findAgentsSourceRoot`'s walk-up-from-`__dirname`, `readGsdCommandNames`) stays real and unrouted — a destination-fake's store starts empty and was never seeded with the repo's own paths, so routing that lookup would make every fake-adapter install throw instead of staging. The symlink-escape guard (`hasExistingSymlinkBetween`) and `assertDestWithinConfigHome` keep their REFUSAL DECISIONS outside this seam — only their `existsSync`/`lstatSync`/`realpathSync` probes route through it, so an injected fake can change what a probe observes but never flip the security decision itself. Writes remain byte-identical to pre-#2874 (AC4/AC5); existing `void`-ignoring callers (`bin/install.js`) are unaffected. Source: `gsd-core/bin/lib/install-fs-adapter.cjs` (generated from `src/install-fs-adapter.cts`). See Runtime Artifact Install Plan Module, ADR-58. +Narrow, enumerated fs seam for the `installRuntimeArtifacts` call tree (`src/install-engine.cts`) — lands ADR-58's never-shipped `cleanup` rollout step (`registry → adapter → helpers → cleanup`, #2874, epic #2866 Phase 5). `installRuntimeArtifacts` now returns the executed plan it ran (`{ runtime, scope, kinds: [{kind, sourceDir, destDir, preserved}], cleanup: [{dir, ok}], postSteps }`) instead of `void`, including on the `combinedFamilyInstall` (OpenCode/Kilo) early-return path — no path may return `undefined` after this phase. Failure is unchanged: stage/rewrite errors still throw rather than becoming an `ok:false` value, so a caller cannot read success-shaped data off a failure path. Delivery is an ambient single mutable adapter (`current`), swapped for the duration of one synchronous install via `withInstallFs(deps.fs, fn)` and always restored in a `finally` — a `deps` parameter threaded through every function on the call tree (`install-profiles.cts`, `runtime-artifact-conversion.cts`, `commonjs-marker.cts`, `installer-migrations.cts`'s two reachable entry points) was rejected as a dozen+-site touch for no behavioral gain over the ambient swap, extending rather than replacing `createRuntimeArtifactInstallPlan`'s existing `deps` bag precedent (Runtime Artifact Install Plan Module). An injected `deps.fs` is a PARTIAL adapter merged over real `node:fs`; any method it omits — except `realpathSync`, the one method documented to degrade gracefully — now THROWS immediately if actually called, naming the missing method, rather than silently resolving to the real filesystem (#2875 defect fix: the prior silent fall-through let a fake adapter missing e.g. `rmSync` perform real destructive IO unnoticed). **Routes destination IO only, by design**: every write/probe against the install destination (copies, removals, snapshot/restore of preserved skill dirs, the manifest read) is fake-able; locating this package's own source tree (`findInstallSourceRoot`/`findAgentsSourceRoot`'s walk-up-from-`__dirname`, `readGsdCommandNames`) stays real and unrouted — a destination-fake's store starts empty and was never seeded with the repo's own paths, so routing that lookup would make every fake-adapter install throw instead of staging. The symlink-escape guard (`hasExistingSymlinkBetween`) and `assertDestWithinConfigHome` keep their REFUSAL DECISIONS outside this seam — only their `existsSync`/`lstatSync`/`realpathSync` probes route through it, so an injected fake can change what a probe observes but never flip the security decision itself. Writes remain byte-identical to pre-#2874 (AC4/AC5); existing `void`-ignoring callers (`bin/install.js`) are unaffected. Source: `gsd-core/bin/lib/install-fs-adapter.cjs` (generated from `src/install-fs-adapter.cts`). See Runtime Artifact Install Plan Module, ADR-58. + +### User Artifact Staging Module +Durable, on-disk staging for `USER_OWNED_ARTIFACTS` (Install Engine Module's `preserveUserArtifacts`/`restoreUserArtifacts` callers) across the preserve → wipe → restore window, closing #1874-F19: an in-memory-only `Map` held across a wipe is silently discarded on process death (Ctrl-C, OOM, a converter throw mid-copy), losing the user's file permanently (#2875, epic #2866 Phase 6, governed by ADR-3574). Interface: `stageUserArtifacts(destDir, fileNames, stagingRoot) -> StagedUserArtifacts`, `restoreStagedUserArtifacts(destDir, staged)`, `discardStagedUserArtifacts(staged)`, `recoverOrphanedUserArtifacts(stagingRoot, configDir) -> RecoveryResult` — four operations rather than two because call sites genuinely differ (one defers to a migration helper instead of restoring; another restores only on migration FAILURE). Synchronous only, every fs call routed through `installFs()` (Install Fs Adapter Module), which now GUARDS a partial injected adapter: any method the partial omits — except the one documented degrade-to-real-fs exception, `realpathSync` — throws immediately if actually called, instead of silently falling through to real fs (#2875 defect fix). Staging layout is fixed by convention — `/.gsd-staging/user-artifacts//{record.json,files/}` — a sibling of every wipe target this phase's four call sites wipe, so staging survives all of them while resolving inside `configDir`; `record.json` is written AFTER every file copy lands, never before, so a half-written staging directory (crash mid-copy) has no record and is never mistaken for a complete one. All staged/restored/recovered names are FLAT (no path separator of either platform's flavor) — matching every real caller's actual usage and rejected the same way traversal/NUL-byte names already were. **Durability alone is not the fix**: a staged copy nothing ever reads back is bytes-safe but user-visibly lost — the #1879-F15 inert-fix failure mode — so `recoverOrphanedUserArtifacts` is wired at the START of `bin/install.js`'s `install()` and `uninstall()`, before the ordinary preserve step, for every runtime; this is the only production entry point that makes recovery reachable rather than merely callable. Its "never throws" contract is enforced with a per-file try/catch (one bad name is reported via `skipped` and the batch continues) wrapped in a per-entry try/catch (one bad batch is reported and the next staging entry is still attempted) — an earlier version left `mkdirSync`/the symlink-safe copy/the final cleanup `rmSync` unguarded, so a single unrecoverable entry (e.g. a directory unexpectedly staged where a file was expected, or `symlinkSync` throwing `EPERM` for an unprivileged Windows user) threw out of the function entirely — before that entry was ever cleaned up — permanently bricking every future install/uninstall (#2875 defect fix). **Carries NO policy** (same discipline as the Install Fs Adapter Module): every path this module writes, and every path recovery reads OUT of an on-disk record before writing to it (attacker-influenceable the moment an install runs on a shared machine), is re-resolved through the SAME `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) every other write on this call tree uses, THEN through the SAME `hasExistingSymlinkBetween` (Install Engine Module) `_copyStaged`/`migrateLegacyDevPreferencesToSkill` apply to their own writes — never reimplemented, and required lazily (call-time, not module-load-time) specifically to avoid a real circular require with Install Engine Module, which imports this module statically. Lexical confinement (`assertDestWithinConfigHome`) alone cannot see a symlinked ANCESTOR directory between `configDir` and a recorded `destDir`; the `hasExistingSymlinkBetween` re-check closes that gap (#2875 defect fix). `recoverOrphanedUserArtifacts` takes `configDir` as a REQUIRED, EXPLICIT parameter — it is never derived from `stagingRoot`'s own path shape, which would rest the confinement guarantee on a naming convention rather than an explicit caller-supplied value. Never overwrites something already present at the recovered destination, decided by `lstatSync` rather than `existsSync` — `existsSync` FOLLOWS symlinks and reports `false` for a DANGLING one, so it cannot see a dangling symlink an attacker planted at the destination to redirect the eventual `copyFileSync`/`symlinkSync` outside `configDir`; `restoreStagedUserArtifacts` applies the same `lstatSync`-based refusal before writing (#2875 defect fix — both were previously `existsSync`-based). Symlink-safe: staged files copy via Installer Migration Module's `copyPreservingSymlink` (itself newly routed through `installFs()` this phase, all five of its fs calls), which never dereferences a symlink — a managed path replaced by a link to (e.g.) `~/.ssh/id_rsa` cannot have the referent's bytes copied into the staging tree or back out of it; a consumer that reads a staged copy's CONTENT back (rather than re-copying it) must separately check for a staged symlink before `readFileSync`, or it will follow the link and read the referent (Install Engine Module's `_runLegacyInstallMigrations` applies this guard). Staging failure is a HARD throw (not swallowed) so a caller cannot proceed to wipe the source directory having staged nothing — worse than no staging at all; recovery and restore/discard degrade instead (missing files, missing staging root, malformed records are all "nothing to do", never a crash). `stagingRoot` confinement against `configDir`, and the symlinked-staging-root refusal (`hasExistingSymlinkBetween`), are both call-site responsibilities (Install Engine Module's `_resolveUserArtifactStagingRoot`, mirrored locally in `bin/install.js`) — this module accepts no `configDir` parameter to `stageUserArtifacts` and cannot perform that outer check itself. **Known limitation, documented rather than closed**: concurrent installs targeting the SAME `destDir` are not safe against each other — the staging key is `sha256(destDir)`, and both the stage-time entryDir-clear and the recovery-time end-of-batch cleanup unconditionally `rmSync` an `entryDir` they did not necessarily create, so two processes racing the same `destDir` can have one wipe the other's in-flight or just-committed batch; closing this fully needs either a cross-process lock (its own crash-safety design surface) or a guarantee installs never run concurrently against one `configDir`, neither of which this module can decide unilaterally. Explicitly out of scope: fsync durability (crash-safe against process death only, not power loss); routing the raw-`fs` uninstall wipe at Install Engine Module's `_runLegacyUninstallCleanup` (only the staging call itself routes through `installFs()` there — the surrounding wipe stays unrouted, matching Phase 5's deliberate exclusion of the uninstall tree). Source: `gsd-core/bin/lib/user-artifact-staging.cjs` (generated from `src/user-artifact-staging.cts`). See Install Engine Module, Install Fs Adapter Module, Runtime Artifact Install Plan Module, Installer Migration Module, ADR-3574. ### Install Scope Module Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle. diff --git a/bin/install.js b/bin/install.js index 00a97cd5a..32f74f78a 100755 --- a/bin/install.js +++ b/bin/install.js @@ -478,14 +478,13 @@ const pkg = require('../package.json'); // of cwd, but keeping the require at the top makes the dependency explicit and // surfaces resolution failures at process start instead of at first install call. const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); -const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs')); const { RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP, isAnthropicFlavoredModel: gsdIsAnthropicFlavoredModel, } = require(path.join(_gsdLibDir, 'model-catalog.cjs')); -const { - resolveTierEntry: gsdResolveTierEntry, -} = require(path.join(_gsdLibDir, 'model-resolver.cjs')); +// #2875 Part 2: MODEL_PROFILES + resolveTierEntry are now consumed only by +// install-model-override-resolver.cjs's readGsdRuntimeProfileResolver +// (required below) — this installer no longer needs its own bindings. // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig / // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile @@ -713,12 +712,11 @@ const { installRuntimeArtifacts, uninstallRuntimeArtifacts, installOpencodeFamilySkills, + installAgentsKindStandalone, _installNativePluginIfDeclared, _copyStaged, hasExistingSymlinkBetween, isSymlinkedDestOptIn, - preserveUserArtifacts, - restoreUserArtifacts, migrateLegacyDevPreferencesToSkill, applyOpencodeFamilyPathPrefix, convertClaudeCommandToOpencodeSkill, @@ -732,6 +730,56 @@ const { _removeHermesBareStemDirs, } = installEngine; +// #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS +// across the preserve -> wipe -> restore window (#1874-F19). See +// src/user-artifact-staging.cts's module doc. +const { + stageUserArtifacts, + restoreStagedUserArtifacts, + discardStagedUserArtifacts, + recoverOrphanedUserArtifacts, +} = require(path.join(_gsdLibDir, 'user-artifact-staging.cjs')); + +/** + * Resolve the durable staging root for `configDir`, confined via + * `assertDestWithinConfigHome` and refused via `hasExistingSymlinkBetween` + * (test-matrix E1/E4) — mirrors install-engine.cts's + * `_resolveUserArtifactStagingRoot`, kept local here because bin/install.js's + * two call sites (uninstall's legacy-migration block, install's mainline + * gsd-core copy) are not inside that module. + */ +function _resolveUserArtifactStagingRoot(configDir) { + const stagingRoot = assertDestWithinConfigHome(configDir, path.posix.join('.gsd-staging', 'user-artifacts')); + if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) { + throw new Error( + `_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`, + ); + } + return stagingRoot; +} + +/** + * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` — mirrors + * install-engine.cts's own `_tryResolveUserArtifactStagingRoot` (kept local + * here for the same reason the throwing version above is: bin/install.js's + * own call sites are not inside that module). A hostile/broken + * `.gsd-staging` path (or a symlinked configDir itself) must never brick + * `install()` or `uninstall()` — before this fix, `_resolveUserArtifactStagingRoot` + * was called UNGUARDED as the first statement of both, so + * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands, including + * uninstall, the remedy for the first problem. Returns `null` (never throws), + * logging one warning; every call site MUST treat `null` as "skip the + * staging-dependent step for this run". + */ +function _tryResolveUserArtifactStagingRoot(configDir) { + try { + return _resolveUserArtifactStagingRoot(configDir); + } catch (err) { + console.warn(` ${yellow}!${reset} user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`); + return null; + } +} + // Parse args const args = process.argv.slice(2); const hasGlobal = args.includes('--global') || args.includes('-g'); @@ -1066,6 +1114,15 @@ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml; // callers continue to work and there is a single implementation. (All call // sites are below this line, so the const binding has no TDZ hazard.) const processAttribution = runtimeArtifactConversion.processAttribution; +// #2875 Part 2: descriptor-driven agent cross-cutting pieces, single-sourced +// in runtimeArtifactConversion so the inline agent loop below and the +// descriptor pipeline (stageAgentsForRuntimeWithConverter) resolve through +// the SAME code — no drift between the two byte-parity-gated pipelines. +const { + deriveAgentName, + applyAgentFrontmatterExtensions, + applyAgentBrandingRewrites, +} = runtimeArtifactConversion; // computePathPrefix / applyRuntimeContentRewritesInPlace / applyRuntimeContentRewritesForCommandsInPlace: // Single implementations now live in runtimeArtifactConversion (ADR-1508 / #1511 Phase 2). // Re-bound here so install.js call sites and exports continue to work unchanged. @@ -1430,296 +1487,30 @@ function writeSettings(settingsPath, settings) { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } -/** - * Read model_overrides from ~/.gsd/defaults.json at install time. - * Returns an object mapping agent names to model IDs, or null if the file - * doesn't exist or has no model_overrides entry. - * Used by Codex TOML and OpenCode agent file generators to embed per-agent - * model assignments so that model_overrides is respected on non-Claude runtimes (#2256). - */ -function readGsdGlobalModelOverrides(options = {}) { - try { - const home = options.homedir ? options.homedir() : os.homedir(); - const defaultsPath = path.join(home, '.gsd', 'defaults.json'); - if (!fs.existsSync(defaultsPath)) return null; - const raw = fs.readFileSync(defaultsPath, 'utf-8'); - const parsed = JSON.parse(raw); - const overrides = parsed.model_overrides; - if (!overrides || typeof overrides !== 'object') return null; - return overrides; - } catch { - return null; - } -} +// #2875 Part 2 (J8): model-override resolution (readGsdGlobalModelOverrides / +// readGsdEffectiveModelOverrides / readGsdRuntimeProfileResolver, plus the +// shared resolveAgentModelOverride precedence chain) was extracted into the +// shipped gsd-core/bin/lib/install-model-override-resolver.cjs, mirroring +// install-effort-resolver.cjs's existing #2071 precedent, so the descriptor- +// driven agents pipeline (runtime-artifact-layout.cts's convertedAgentsKind) +// and this installer resolve model_overrides / model_profile_overrides +// through the SAME code — a single source of truth for the precedence chain +// the inline agent loop below used to duplicate across ~24 lines per runtime +// (kilo, opencode). See install-model-override-resolver.cts's module doc. +const { + readGsdEffectiveModelOverrides, + readGsdRuntimeProfileResolver, + resolveAgentModelOverride, +} = require(path.join(_gsdLibDir, 'install-model-override-resolver.cjs')); -/** - * Effective per-agent model_overrides for the Codex / OpenCode install paths. - * - * Merges `~/.gsd/defaults.json` (global) with per-project - * `/.planning/config.json`. Per-project keys win on conflict so a - * user can tune a single agent's model in one repo without re-setting the - * global defaults for every other repo. Non-conflicting keys from both - * sources are preserved. - * - * This is the fix for #2256: both adapters previously read only the global - * file, so a per-project `model_overrides` (the common case the reporter - * described — a per-project override for `gsd-codebase-mapper` in - * `.planning/config.json`) was silently dropped and child agents inherited - * the session default. - * - * `targetDir` is the consuming runtime's install root (e.g. `~/.codex` for - * a global install, or `/.codex` for a local install). We walk up - * from there looking for `.planning/` so both cases resolve the correct - * project root. When `targetDir` is null/undefined only the global file is - * consulted (matches prior behavior for code paths that have no project - * context). - * - * Returns a plain `{ agentName: modelId }` object, or `null` when neither - * source defines `model_overrides`. - */ -function readGsdEffectiveModelOverrides(targetDir = null, options = {}) { - const global = readGsdGlobalModelOverrides(options); - - let projectOverrides = null; - if (targetDir) { - let probeDir = path.resolve(targetDir); - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join(probeDir, '.planning', 'config.json'); - if (fs.existsSync(candidate)) { - try { - const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8')); - if (parsed && typeof parsed === 'object' && parsed.model_overrides - && typeof parsed.model_overrides === 'object') { - projectOverrides = parsed.model_overrides; - } - } catch { - // Malformed config.json — fall back to global; readGsdRuntimeProfileResolver - // surfaces a parse warning via _readGsdConfigFile already. - } - break; - } - const parent = path.dirname(probeDir); - if (parent === probeDir) break; - probeDir = parent; - } - } - - if (!global && !projectOverrides) return null; - // Per-project wins on conflict; preserve non-conflicting global keys. - return { ...(global || {}), ...(projectOverrides || {}) }; -} - -/** - * #443 — Inject `effort: ` into YAML frontmatter of a Claude .md agent - * file in a newline-agnostic way (LF and CRLF source files are both handled). - * - * The function: - * - Detects the file's EOL (CRLF if the first `---` line ends with \r\n, - * otherwise LF). - * - Skips injection if an `effort:` key already exists in the frontmatter - * (idempotent). - * - Inserts `effort: ` immediately before the closing `---` delimiter, - * using the same EOL as the surrounding frontmatter so the output file - * stays EOL-consistent. - * - Returns the original content unchanged when no YAML frontmatter is found. - * - * @param {string} content Raw file content (may have LF or CRLF endings). - * @param {string} effortValue Rendered effort string, e.g. "xhigh". - * @returns {string} Updated content with `effort:` injected, or the - * original content when no frontmatter is found. - */ -function injectEffortFrontmatter(content, effortValue) { - // Detect the dominant EOL from the first line (the opening `---`). - // If the very first `---` is followed by \r\n, treat the whole file as CRLF. - const eol = /^---\r\n/.test(content) ? '\r\n' : '\n'; - - // Build a frontmatter-matching regex that tolerates an optional \r before - // each \n, so we handle both LF and CRLF files without needing to normalise - // the whole content. - // - // Breakdown: - // ^---\r?\n — opening delimiter (with optional \r) - // ([\s\S]*?) — frontmatter body (non-greedy) - // ^---\r?$ — closing delimiter line (optional \r, $ before \n in - // multiline mode) - // (\r?\n|$) — newline after closing --- (or end of string) - // - // The `m` flag makes ^ / $ match at every line boundary. - const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m; - const match = fmRe.exec(content); - if (!match) return content; // no YAML frontmatter — leave unchanged - - // Idempotency guard: don't insert a second effort: line. - const fmBody = match[1]; // content between the two `---` lines - if (/^effort:/m.test(fmBody)) return content; - - // Locate the exact position of the closing `---` line so we can insert - // before it using a simple string splice (avoids re-running the regex and - // avoids any edge-cases with $ matching \r differently per engine). - const closeIdx = match.index + 4 + fmBody.length; // 4 = len("---\n") (opening) - // Actually compute based on the full match start + captured group length: - // match[0] = full frontmatter block; match.index = start of that block. - // The closing `---` starts at: match.index + ("---" + eol).length + fmBody.length - const openLen = 3 + eol.length; // "---" + eol - const closingStart = match.index + openLen + fmBody.length; - - const before = content.slice(0, closingStart); - const after = content.slice(closingStart); - return `${before}effort: ${effortValue}${eol}${after}`; -} - -/** - * #767 — Inject `disallowedTools: ` into the YAML frontmatter of a Claude .md agent. - * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present), - * inserts immediately before the closing `---`. Claude-only — never call for other runtimes, - * which break on unknown frontmatter keys. - */ -function injectDisallowedToolsFrontmatter(content, disallowedValue) { - // Detect the dominant EOL from the first line (the opening `---`). - // If the very first `---` is followed by \r\n, treat the whole file as CRLF. - const eol = /^---\r\n/.test(content) ? '\r\n' : '\n'; - - // Build a frontmatter-matching regex that tolerates an optional \r before - // each \n, so we handle both LF and CRLF files without needing to normalise - // the whole content. - const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m; - const match = fmRe.exec(content); - if (!match) return content; // no YAML frontmatter — leave unchanged - - // Idempotency guard: don't insert a second disallowedTools: line. - const fmBody = match[1]; // content between the two `---` lines - if (/^disallowedTools:/m.test(fmBody)) return content; - - // Locate the exact position of the closing `---` line so we can insert - // before it using a simple string splice. - const openLen = 3 + eol.length; // "---" + eol - const closingStart = match.index + openLen + fmBody.length; - - const before = content.slice(0, closingStart); - const after = content.slice(closingStart); - return `${before}disallowedTools: ${disallowedValue}${eol}${after}`; -} - -// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list. -// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one -// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally -// excluded (it legitimately uses Write AND Edit to create/patch test files). -const READONLY_AGENT_DISALLOWED_TOOLS = { - 'gsd-plan-checker': 'Write, Edit, MultiEdit', - 'gsd-integration-checker': 'Write, Edit, MultiEdit', - 'gsd-ui-checker': 'Write, Edit, MultiEdit', - 'gsd-verifier': 'Edit, MultiEdit', - 'gsd-doc-verifier': 'Edit, MultiEdit', - 'gsd-eval-auditor': 'Edit, MultiEdit', - 'gsd-ui-auditor': 'Edit, MultiEdit', -}; - -/** - * #2517 — Build a runtime-aware tier resolver for the install path. - * - * Probes BOTH per-project `/.planning/config.json` AND - * `~/.gsd/defaults.json`, with per-project keys winning over global. This - * matches `loadConfig`'s precedence and is the only way the PR's headline claim - * — "set runtime in .planning/config.json and the Codex TOML emit picks it up" - * — actually holds end-to-end (review finding #1). - * - * `targetDir` should be the consuming runtime's install root — install code - * passes `path.dirname()` so `.planning/config.json` resolves - * relative to the user's project. When `targetDir` is null/undefined, only the - * global defaults are consulted. - * - * Returns null if no `runtime` is configured (preserves prior behavior — only - * model_overrides is embedded, no tier/reasoning-effort inference). Returns - * null when `model_profile` is `inherit` so the literal alias passes through - * unchanged. Returns null when no project config is reachable AND - * `~/.gsd/defaults.json` declares no `model_profile` (#3543): the profile is - * unverifiable at global scope, and baking the 'balanced' default would - * defeat a consuming project's explicit `inherit`. - * - * Returns { runtime, resolve(agentName) -> { model, reasoning_effort? } | null } - */ -function readGsdRuntimeProfileResolver(targetDir = null) { - const homeDefaults = _readGsdConfigFile( - path.join(os.homedir(), '.gsd', 'defaults.json'), - '~/.gsd/defaults.json' - ); - - // Per-project config probe. Resolve the project root by walking up from - // targetDir until we hit a `.planning/` directory; this covers both the - // common case (caller passes the project root) and the case where caller - // passes a nested install dir like `/.codex/`. - let projectConfig = null; - if (targetDir) { - let probeDir = path.resolve(targetDir); - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join(probeDir, '.planning', 'config.json'); - if (fs.existsSync(candidate)) { - projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); - break; - } - const parent = path.dirname(probeDir); - if (parent === probeDir) break; - probeDir = parent; - } - } - - // Per-project wins. Only fall back to ~/.gsd/defaults.json when the project - // didn't set the field. Field-level merge (not whole-object replace) so a - // user can keep `runtime` global while overriding only `model_profile` per - // project, and vice versa. - const merged = { - runtime: - (projectConfig && projectConfig.runtime) || - (homeDefaults && homeDefaults.runtime) || - null, - model_profile: - (projectConfig && projectConfig.model_profile) || - (homeDefaults && homeDefaults.model_profile) || - 'balanced', - model_profile_overrides: - (projectConfig && projectConfig.model_profile_overrides) || - (homeDefaults && homeDefaults.model_profile_overrides) || - null, - }; - - if (!merged.runtime) return null; - - // #3543 — "no project config found" is not "profile absent". The probe - // above starts at the install's targetDir, which for a GLOBAL install - // (~/.config/) can never reach the consuming project's - // .planning/config.json — and writeNonClaudeDefaults never stores - // model_profile in ~/.gsd/defaults.json. Falling through to 'balanced' - // here baked a tier-default model (e.g. anthropic/claude-opus-4-8) into - // the static OpenCode/Kilo agent frontmatter, defeating a project's - // explicit `model_profile: "inherit"` — those runtimes use the frontmatter - // model over the live session selection. A profile is bakeable only when - // verifiable: declared in the found project config (local install — - // loadConfig reads the same file at dispatch time) or in the machine-wide - // defaults. Otherwise bake nothing and let the runtime's default/session - // model govern — the documented non-Claude posture - // (references/model-profiles.md, #1156). - if (!projectConfig && !(homeDefaults && homeDefaults.model_profile)) { - return null; - } - - const profile = String(merged.model_profile).toLowerCase(); - if (profile === 'inherit') return null; - - return { - runtime: merged.runtime, - resolve(agentName) { - const agentModels = GSD_MODEL_PROFILES[agentName]; - if (!agentModels) return null; - const tier = agentModels[profile] || agentModels.balanced; - if (!tier) return null; - return gsdResolveTierEntry({ - runtime: merged.runtime, - tier, - overrides: merged.model_profile_overrides, - }); - }, - }; -} +// #2875 Part 2: effort frontmatter injection moved to runtimeArtifactConversion +// (single source of truth with the descriptor pipeline's +// applyAgentFrontmatterExtensions step, which now also owns disallowedTools +// injection + the read-only agent deny-list internally — see its module doc +// in src/runtime-artifact-conversion.cts). injectEffortFrontmatter is kept +// bound here only because it is still part of this module's export surface +// (tests reach it via require('../bin/install.js')). +const { injectEffortFrontmatter } = runtimeArtifactConversion; // Cache for attribution settings (populated once per runtime during install) const attributionCache = new Map(); @@ -7709,8 +7500,7 @@ function writeHermesCategoryDescription(categoryDir) { * @param {boolean} isGlobal - Whether this is a global install */ -// USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts, -// migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries, +// USER_OWNED_ARTIFACTS, migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries, // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir, // _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts, // installOpencodeFamilySkills, uninstallRuntimeArtifacts: @@ -8283,6 +8073,23 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { let removedCount = 0; + // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user + // artifact orphaned by a PRIOR uninstall run that died between staging and + // its own restore/discard, BEFORE this run's own preserve steps (sites 2, + // 3, 5 below) stage anything new. Uninstall's own gsd-core/ removal and + // legacy-commands cleanup are exactly as crash-exposed as install's — + // without this, an orphan from a crashed uninstall is recoverable only if + // the user later re-installs. + // #2875 defect fix: DEGRADE, never abort uninstall, when the staging root + // itself cannot be resolved — skip this recovery pass rather than throw + // out of uninstall() before it does anything. + { + const _uninstallEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_uninstallEntryStagingRoot !== null) { + recoverOrphanedUserArtifacts(_uninstallEntryStagingRoot, targetDir); + } + } + // Remove profile marker so a clean reinstall defaults to full surface. try { fs.unlinkSync(path.join(targetDir, '.gsd-profile')); @@ -8626,18 +8433,42 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // Preserve user-owned dev-preferences.md if present (#1423 parity). const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyGsdCommandsDir)) { - const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md'); - const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null; - fs.rmSync(legacyGsdCommandsDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); - if (savedDevPrefs) { - try { - fs.mkdirSync(legacyGsdCommandsDir, { recursive: true }); - fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs); - console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); - } catch (err) { - console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); + // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 / + // #1874-F19 "site 7" — found by sweeping bin/install.js for the + // read-then-wipe-then-write PATTERN, not for preserveUserArtifacts' + // callers; this uninstall-path block open-coded the same round-trip). + // #2875 defect fix: DEGRADE, never abort uninstall, when the staging + // root cannot be resolved — skip this legacy-cleanup block entirely + // (leave the stale dir in place) rather than wipe without a durable + // backup for dev-preferences.md. + const _legacyGsdCommandsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_legacyGsdCommandsStagingRoot !== null) { + const stagedDevPrefs = stageUserArtifacts(legacyGsdCommandsDir, ['dev-preferences.md'], _legacyGsdCommandsStagingRoot); + // Preserve the ORIGINAL truthy-content check exactly: an existing but + // EMPTY dev-preferences.md was (and still is) silently not restored. + const savedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md') + ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8') + : null; + fs.rmSync(legacyGsdCommandsDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + if (savedDevPrefs) { + try { + restoreStagedUserArtifacts(legacyGsdCommandsDir, stagedDevPrefs); + discardStagedUserArtifacts(stagedDevPrefs); + console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); + } catch (err) { + console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); + } + } else { + // #2875 defect fix: an existing-but-EMPTY dev-preferences.md was (and + // still is) never restored — the original truthy-content check is + // preserved byte-for-byte above — but the staged batch was never + // discarded either, leaking a /.gsd-staging/ record + // forever and re-materializing the just-deleted file on a future + // install's orphan-recovery pass. Discard unconditionally when there + // is nothing to restore. + discardStagedUserArtifacts(stagedDevPrefs); } } } @@ -8655,21 +8486,77 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // so this is a best-effort guard. const legacyDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyDir, ['dev-preferences.md']); - fs.rmSync(legacyDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); - const _uninstallScope = scope; - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) { - // Compute the actual path written so the log line is accurate per-runtime - const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope); - const _sk = _layout.kinds.find((k) => k.kind === 'skills'); - const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; - const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md'; - console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`); - } else { - // Migration failed or already exists — restore to legacy location so user content is not lost - restoreUserArtifacts(legacyDir, savedLegacyArtifacts); + // #2875 (#1874-F19): staged DURABLY to disk before the wipe below, + // instead of an in-memory Map only — a crash between the wipe and the + // restore-on-failure branch below now survives via + // recoverOrphanedUserArtifacts on the next run. + // #2875 defect fix: DEGRADE, never abort uninstall, when the staging + // root cannot be resolved — skip this legacy-migration block entirely + // (leave the stale dir in place) rather than wipe without a durable + // backup. + const stagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (stagingRoot !== null) { + const stagedLegacyArtifacts = stageUserArtifacts(legacyDir, ['dev-preferences.md'], stagingRoot); + fs.rmSync(legacyDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + const _uninstallScope = scope; + // migrateLegacyDevPreferencesToSkill's Map contract is + // unchanged — read the staged content back from disk (not an in-memory + // value held across the wipe above). + // + // #2875 defect fix (readFileSync following a staged symlink) — matches + // install-engine.cts's _runLegacyInstallMigrations call site 1 exactly: + // readFileSync ALWAYS follows a symlink, so a staged artifact that is + // itself a symlink (user-artifact-staging.cts's "Symlink safety": a + // symlinked user artifact is recreated AS a symlink in the staging + // tree, never copied by content) would have its REFERENT's bytes read + // here and land in SKILL.md. Excluded from migration below and + // restored to its original location unchanged instead. + // #2875 defect fix (regression closed — was previously unguarded and + // BRICKED uninstall, the very command that should recover from this): + // legacyDir was already removed above, so stagedLegacyArtifacts is + // the only surviving copy. migrateLegacyDevPreferencesToSkill + // correctly THROWS when it finds a planted/dangling symlink at the + // skill-file leaf (security fix); a raw `fs.lstatSync` in the loop + // below can also throw on a TOCTOU-vanished staged file. Either one, + // left unguarded, propagated straight out of uninstall, aborting it + // WITHOUT ever reaching the restore-or-discard branch below — the + // staged batch was orphaned on disk and every retry hit the same + // throw again. Degrade identically to every other #2875 staging step + // in this function: catch, warn once, and treat the batch as + // unmigrated so the restore branch below always fires. + let _legacyMigrated = false; + let migratableLegacyNames = []; + try { + const savedLegacyArtifacts = new Map(); + for (const name of stagedLegacyArtifacts.names) { + const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name); + if (fs.lstatSync(stagedPath).isSymbolicLink()) continue; + savedLegacyArtifacts.set(name, fs.readFileSync(stagedPath, 'utf8')); + migratableLegacyNames.push(name); + } + _legacyMigrated = migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope); + } catch (err) { + console.warn(` ${yellow}!${reset} dev-preferences.md migration skipped (${err.message}) — restoring the legacy copy instead.`); + _legacyMigrated = false; + migratableLegacyNames = []; + } + if (_legacyMigrated && migratableLegacyNames.length === stagedLegacyArtifacts.names.length) { + // Compute the actual path written so the log line is accurate per-runtime + const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope); + const _sk = _layout.kinds.find((k) => k.kind === 'skills'); + const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; + const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md'; + console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`); + discardStagedUserArtifacts(stagedLegacyArtifacts); + } else { + // Migration failed, already exists, or a symlinked name was excluded + // above — restore the WHOLE batch to the legacy location so no user + // content is silently lost. + restoreStagedUserArtifacts(legacyDir, stagedLegacyArtifacts); + discardStagedUserArtifacts(stagedLegacyArtifacts); + } } } } @@ -8677,22 +8564,49 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) { // 2. Remove gsd-core directory const gsdDir = path.join(targetDir, 'gsd-core'); if (fs.existsSync(gsdDir)) { - // Preserve user-generated files before wipe (#1423) - const userProfilePath = path.join(gsdDir, 'USER-PROFILE.md'); - const preservedProfile = fs.existsSync(userProfilePath) ? fs.readFileSync(userProfilePath, 'utf-8') : null; + // Stage user-generated files DURABLY to disk before wipe (#1423; #2875 / + // #1874-F19 "site 5" — this block open-coded its own preserve/restore + // instead of calling preserveUserArtifacts, which is why it was missed + // by the original symbol-search measurement). + // #2875 defect fix: this IS the core uninstall step (removing gsd-core/) + // — unlike the optional legacy-cleanup blocks above, uninstall must + // still be able to proceed and actually remove gsd-core/ even when the + // staging root cannot be resolved. Degrade by skipping ONLY the + // USER-PROFILE.md preserve/restore wrapper (warn), never the removal + // itself. + const _gsdDirStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_gsdDirStagingRoot === null) { + console.warn(` ${yellow}!${reset} Skipping gsd-core/USER-PROFILE.md preservation (staging unavailable) — it will be lost if present.`); + fs.rmSync(gsdDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed gsd-core/`); + } else { + const stagedProfile = stageUserArtifacts(gsdDir, USER_OWNED_ARTIFACTS, _gsdDirStagingRoot); + // Preserve the ORIGINAL truthy-content check exactly: an existing but + // EMPTY USER-PROFILE.md was (and still is) silently not restored — + // matching prior behavior byte-for-byte rather than widening scope. + const preservedProfile = stagedProfile.names.includes('USER-PROFILE.md') + ? fs.readFileSync(path.join(stagedProfile.filesDir, 'USER-PROFILE.md'), 'utf8') + : null; - fs.rmSync(gsdDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed gsd-core/`); + fs.rmSync(gsdDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed gsd-core/`); - // Restore user-generated files - if (preservedProfile) { - try { - fs.mkdirSync(gsdDir, { recursive: true }); - fs.writeFileSync(userProfilePath, preservedProfile); - console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`); - } catch (err) { - console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`); + // Restore user-generated files + if (preservedProfile) { + try { + restoreStagedUserArtifacts(gsdDir, stagedProfile); + discardStagedUserArtifacts(stagedProfile); + console.log(` ${green}✓${reset} Preserved gsd-core/USER-PROFILE.md`); + } catch (err) { + console.error(` ${red}✗${reset} Failed to restore USER-PROFILE.md: ${err.message}`); + } + } else { + // #2875 defect fix: same empty-file orphan leak as the legacy + // commands/gsd/ site above — discard the staging batch regardless of + // whether the staged content was truthy. + discardStagedUserArtifacts(stagedProfile); } } } @@ -9733,11 +9647,11 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { const gsdHashes = generateManifest(gsdDir); for (const [rel, hash] of Object.entries(gsdHashes)) { - // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are preserved - // across reinstalls by preserveUserArtifacts and must NOT be hashed into - // the manifest — otherwise saveLocalPatches() would flag every refresh - // as a "local patch" (bug #2771). Single source of truth: - // USER_OWNED_ARTIFACTS at top of file. + // Skip user-owned artifacts (e.g. USER-PROFILE.md). They are staged + // durably and restored across reinstalls (user-artifact-staging.cts, + // #2875) and must NOT be hashed into the manifest — otherwise + // saveLocalPatches() would flag every refresh as a "local patch" + // (bug #2771). Single source of truth: USER_OWNED_ARTIFACTS at top of file. if (USER_OWNED_ARTIFACTS.includes(rel)) continue; manifest.files['gsd-core/' + rel] = hash; } @@ -10225,21 +10139,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // below were removed, leaving isKimi unused in this function (the kimi // local-install-deferred branch above already reads // _hostBehaviors(runtime).localInstallDeferred instead of this flag). - // #2096: isAntigravity dropped — antigravity is in - // _DESCRIPTOR_AGENTS_RUNTIMES below, so its two legacy-agent-loop branches - // (the path-rewrite skip and the converter dispatch) were unreachable dead - // code; both were removed rather than re-gated on hostBehaviors. - // #2098: isCodebuddy dropped — codebuddy is also in - // _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch - // (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was + // #2096: isAntigravity dropped — antigravity's agents were already + // descriptor-driven (installRuntimeArtifacts), so its two legacy-agent-loop + // branches (the path-rewrite skip and the converter dispatch) were + // unreachable dead code; both were removed rather than re-gated on + // hostBehaviors. (#2875 Part 2 later deleted that legacy loop and its + // `_DESCRIPTOR_AGENTS_RUNTIMES` gate entirely — EVERY runtime is now + // descriptor-driven for agents, not just this subset.) + // #2098: isCodebuddy dropped — codebuddy's agents were likewise already + // descriptor-driven, so its legacy converter-dispatch branch (the + // `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was // unreachable dead code and was removed rather than re-gated. - // #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES - // below, so its three legacy-agent-loop branches (the path-rewrite skip, - // the converter dispatch, and the .agent.md destName ternary) were - // unreachable dead code and were removed rather than re-gated; the - // .agent.md suffix now lives on hostBehaviors.agentFileExtension in - // src/install-engine.cts, and the skipSharedHooksInstall check above no - // longer needs `&& !isCopilot`. + // #2099: isCopilot dropped — copilot's agents were likewise already + // descriptor-driven, so its three legacy-agent-loop branches (the + // path-rewrite skip, the converter dispatch, and the .agent.md destName + // ternary) were unreachable dead code and were removed rather than + // re-gated; the .agent.md suffix now lives on + // hostBehaviors.agentFileExtension in src/install-engine.cts, and the + // skipSharedHooksInstall check above no longer needs `&& !isCopilot`. // #2100: isWindsurf dropped — its four former isWindsurf-gated branches // (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the // workflow-verification report, and the shared-hooks-install exclusion) are @@ -10247,8 +10164,8 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // hostBehaviors.installsCommandBodiesForWorkflowDelegation, // hostBehaviors.verificationStyle === 'windsurf-workflows', and // hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop - // converter arm was likewise unreachable dead code (windsurf is in - // _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above. + // converter arm was likewise unreachable dead code (windsurf's agents were + // already descriptor-driven) and was removed above. // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall. const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime); const plan = resolveInstallPlan(runtime); @@ -10336,6 +10253,26 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { ? process.cwd() : path.join(process.cwd(), dirName); + // #2875 (#1874-F19 anti-inertness, test-matrix C7): recover any user + // artifact orphaned by a PRIOR install run that died between staging and + // its own restore/discard, BEFORE this run's own preserve step stages + // anything new. This is the production entry point every install() call + // reaches — the only place this phase's durability fix is complete rather + // than merely callable (40-design.md "The inertness trap this design must + // avoid" / #1879-F15). Runs for every runtime, ahead of both the + // layout-driven path's _runLegacyInstallMigrations (site 1, inside + // installRuntimeArtifacts) and this function's own mainline gsd-core copy + // (site 4, below). + // #2875 defect fix: DEGRADE, never abort install, when the staging root + // itself cannot be resolved — skip this recovery pass rather than throw + // out of install() before it does anything. + { + const _installEntryStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_installEntryStagingRoot !== null) { + recoverOrphanedUserArtifacts(_installEntryStagingRoot, targetDir); + } + } + const locationLabel = isGlobal ? targetDir.replace(os.homedir(), '~') : targetDir.replace(process.cwd(), '.'); @@ -10727,6 +10664,17 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // (copyWithPathReplacement + stale-skills cleanup). const _isSkillsRuntime = (() => { if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086) + // #2875 Part 2 defect fix: a runtime whose LOCAL commands are embedded in a + // rules file rather than materialized as files (hostBehaviors.localCommandsViaRules + // — cline is the only declarant, capabilities/cline/capability.json) must not + // flip into this skills/commands-reporting branch merely because its local + // artifactLayout now also declares an `agents` kind (#2875 Part 2 cline-local + // agents regression fix). That branch's own verification reporting expects a + // skills/ or commands/ directory this runtime never writes locally and would + // spuriously fail; the `localCommandsViaRules` branch below (unchanged + // messaging) and the unconditional agents-materialization block further down + // (installAgentsKindStandalone) already cover this runtime/scope correctly. + if (!isGlobal && _hostBehaviors(runtime).localCommandsViaRules) return false; const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime]; const layout = cap && cap.runtime && cap.runtime.artifactLayout; if (!layout) return false; @@ -11011,15 +10959,34 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // that used the namespaced layout (wrote bare-name files under commands/gsd/). const legacyGsdDir = path.join(commandsDir, 'gsd'); if (fs.existsSync(legacyGsdDir)) { - // Preserve user-owned dev-preferences.md before wiping - const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md'); - const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; - fs.rmSync(legacyGsdDir, { recursive: true }); - console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-.md layout)`); - if (preservedDevPrefs) { - // Migrate dev-preferences to the new flat form - fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs); - console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`); + // Stage user-owned dev-preferences.md DURABLY before wiping (#2875 / + // #1874-F19 "site 6" — this Claude commands-install path open-coded + // its own preserve/restore instead of calling preserveUserArtifacts, + // found by sweeping for the read-then-wipe-then-write PATTERN rather + // than for that helper's callers). + // #2875 defect fix: DEGRADE, never abort install, when the staging + // root cannot be resolved — skip this legacy-migration block entirely + // (leave the stale dir in place) rather than wipe without a durable + // backup. + const _legacyGsdStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_legacyGsdStagingRoot !== null) { + const stagedDevPrefs = stageUserArtifacts(legacyGsdDir, ['dev-preferences.md'], _legacyGsdStagingRoot); + // Preserve the ORIGINAL truthy-content check exactly: an existing but + // EMPTY dev-preferences.md was (and still is) silently not migrated — + // matching prior behavior byte-for-byte rather than widening scope. + const preservedDevPrefs = stagedDevPrefs.names.includes('dev-preferences.md') + ? fs.readFileSync(path.join(stagedDevPrefs.filesDir, 'dev-preferences.md'), 'utf8') + : null; + fs.rmSync(legacyGsdDir, { recursive: true }); + console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-.md layout)`); + if (preservedDevPrefs) { + // Migrate dev-preferences to the new flat form — a RENAME on + // restore (staged as 'dev-preferences.md', restored as + // 'gsd-dev-preferences.md'), not a round-trip. + restoreStagedUserArtifacts(commandsDir, stagedDevPrefs, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } }); + console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`); + } + discardStagedUserArtifacts(stagedDevPrefs); } } @@ -11050,12 +11017,29 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } // Copy gsd-core skill with path replacement - // Preserve user-generated files before the wipe-and-copy so they survive re-install + // Stage user-generated files DURABLY to disk before the wipe-and-copy so + // they survive re-install even if the process dies mid-copy (#2875 / + // #1874-F19) — copyWithPathReplacement wipes and recursively re-copies the + // entire gsd-core/ tree, the single longest operation in the install, on + // the path every user takes (40-design.md "Site 4 is far worse..."). const skillSrc = path.join(src, 'gsd-core'); const skillDest = path.join(targetDir, 'gsd-core'); - const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS); - copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir); - restoreUserArtifacts(skillDest, savedGsdArtifacts); + // #2875 defect fix: this IS the mainline install step (installing + // gsd-core/ itself) — unlike the optional legacy-cleanup blocks above, + // install must still be able to proceed and actually write gsd-core/ even + // when the staging root cannot be resolved. Degrade by skipping ONLY the + // USER_OWNED_ARTIFACTS preserve/restore wrapper around the copy (warn), + // never the copy itself. + const _gsdArtifactsStagingRoot = _tryResolveUserArtifactStagingRoot(targetDir); + if (_gsdArtifactsStagingRoot === null) { + console.warn(` ${yellow}!${reset} Skipping gsd-core/${USER_OWNED_ARTIFACTS.join(', gsd-core/')} preservation (staging unavailable) — it will be lost if present.`); + copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir); + } else { + const stagedGsdArtifacts = stageUserArtifacts(skillDest, USER_OWNED_ARTIFACTS, _gsdArtifactsStagingRoot); + copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir); + restoreStagedUserArtifacts(skillDest, stagedGsdArtifacts); + discardStagedUserArtifacts(stagedGsdArtifacts); + } if (verifyInstalled(skillDest, 'gsd-core')) { console.log(` ${green}✓${reset} Installed workflow assets`); } else { @@ -11117,229 +11101,89 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { } } - // Copy agents to agents directory. - // Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded - // into the runtime's Agent tool schema, costing ~6k tokens per turn even - // when no GSD workflow is active. See open-gsd/gsd-core#2762. - // Note: agentsSrc is declared as let before the enclosing try block so it - // is accessible by installCodexConfig() in the Codex config section below. - agentsSrc = _stageAgents(path.join(src, 'agents')); - const agentsDest = path.join(targetDir, 'agents'); - - // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent - // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path - // applies path-rewrite + attribution + converter + normalize via - // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in - // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE - // (by installRuntimeArtifacts at line 8912), which also performs its own - // stale-file prune pass. The inline stale-removal + inline loop both skip them. - // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together. - // #1575: copilot and antigravity cut over — copilot gets .agent.md filename - // rename via _copyStaged(runtime); antigravity uses scope-aware converter. - // #2092 Phase B Upgrade 1: qwen cut over — native .qwen/agents/*.md subagent - // projection via convertClaudeAgentToQwenAgent. Without this exclusion the - // legacy inline loop below deletes+re-copies qwen's agents RAW (bypassing the - // new converter entirely, since qwen has no dedicated branch in the inline - // loop's if/else-if chain — it would silently fall through to the generic - // brandingRewrites-only branch). - // cline remains excluded: rules-only local branch + local/global complication - // that the descriptor-driven path does not handle correctly. - // #3384: zcode cut over — its agents kind now declares - // convertClaudeAgentToZcodeAgent (strips mcp__* grants ZCode's dispatcher - // treats as required MCP servers). Without this exclusion the legacy inline - // loop below deletes+re-copies zcode's agents RAW, bypassing the converter - // (the same hazard the qwen comment above documents). - const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen', 'kimi', 'zcode']); - - // Always remove stale gsd-* agents first so re-installing with - // `--minimal` actually shrinks a previously-full install. - // For Codex this also covers per-agent `.toml` files alongside the `.md` - // sources so a full → minimal switch doesn't leave stale registrations. - // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes) and - // for pluginOnlyInstall runtimes (pi, ADR-1239 / #2102 Stage 1 — no agents/ - // dir is ever written for them, see the leading branch below). - if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && !_hostBehaviors(runtime).pluginOnlyInstall && fs.existsSync(agentsDest)) { - for (const file of fs.readdirSync(agentsDest)) { - if ( - file.startsWith('gsd-') && - (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml'))) - ) { - fs.unlinkSync(path.join(agentsDest, file)); - } - } - } - + // Agents directory materialization. + // #2875 Part 2 (the agents-bypass closure): EVERY runtime is now + // descriptor-driven for agents — installRuntimeArtifacts (called earlier in + // this function, the `_isSkillsRuntime` branch above) already wrote + // agents/ for any runtime whose capability.json declares an `agents` kind, + // via convertedAgentsKind/agentsKind (generic layout loop) or + // installAgentsKindStandalone (OpenCode/Kilo's combinedFamilyInstall + // branch, called from within installOpencodeFamilyArtifacts) — both reuse + // the SAME stageAgentsForRuntimeWithConverter pipeline (path-rewrite → + // attribution → converter → frontmatter extensions → normalize) the + // inline loop this replaces used to hand-roll, and both prune stale gsd-* + // entries via their own _removeGsdEntries pass BEFORE copying (broader + // than this loop's old extension-gated stale check — see + // runtime-artifact-layout.cts's convertedAgentsKind doc comment). + // Minimal-mode agent filtering is handled the SAME way it already was for + // the ten runtimes cut over before this change: via resolvedProfile.agents + // at staging time, not a separate branch here. + // + // `!_isSkillsRuntime` runtimes (claude-local's legacy-flat local path, and + // pi) never reach that loop at all — installAgentsKindStandalone is called + // here explicitly to cover them (install-engine.cts's own doc comment + // explains why; a regression here was caught by the install-tree golden + // fixture, tests/fixtures/install-tree/claude-local.json). It is a no-op + // for pi: its capability.json declares an EMPTY artifactLayout for both + // scopes (programmatic dispatch, no named-dispatch subagent toolkit, no + // host-read markdown surface), so the resolved layout has no `agents` kind + // to stage and the function returns `null` without writing anything. if (_hostBehaviors(runtime).pluginOnlyInstall) { - // pi (ADR-1239 / #2102 Stage 1): programmatic dispatch has no named-dispatch - // subagent toolkit (dispatch.subagentToolkit: "undocumented", no Agent-tool - // equivalent) and no host-read markdown surface — skip writing agents/ entirely. console.log(` ${green}✓${reset} pi: no subagent files (programmatic dispatch, no named-dispatch toolkit)`); - } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) { - // installRuntimeArtifacts already wrote agents + handles stale-file cleanup - // via its own prune pass. No further action needed. + } else if (_isSkillsRuntime) { console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`); - } else if (isMinimalMode(_effectiveInstallMode)) { - // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections. - // Without stripping them here, a full → minimal reinstall would leave the - // runtime advertising the old full agent surface even though the agent - // files are gone. Reuse the same helper that powers `--uninstall`. - if (_hostBehaviors(runtime).tomlConfigInstall) { - const codexConfigPath = path.join(targetDir, 'config.toml'); - if (fs.existsSync(codexConfigPath)) { - const existing = fs.readFileSync(codexConfigPath, 'utf8'); - const cleaned = stripGsdFromCodexConfig(existing); - if (cleaned === null) { - fs.unlinkSync(codexConfigPath); - } else if (cleaned !== existing) { - fs.writeFileSync(codexConfigPath, cleaned); - } + } else { + const _standaloneAgentsResult = installAgentsKindStandalone(runtime, targetDir, _installScopeId, _resolvedProfile, pathPrefix, getCommitAttribution, _installedCapabilityRegistry); + if (_standaloneAgentsResult) { + // #2875 defect fix: installAgentsKindStandalone now returns `null` + // (rather than a truthy result pointing at an empty destDir) whenever a + // restricted (non-'*') resolvedProfile — --minimal being the common + // case — legitimately stages ZERO agents, matching the pre-#2875-Part-2 + // inline loop's behavior of never creating agentsDest under --minimal + // at all (see the deleted `isMinimalMode` branch). `destDir` is + // therefore guaranteed non-empty whenever we reach this branch, so a + // real staging failure still fails loudly via verifyInstalled below. + if (verifyInstalled(_standaloneAgentsResult.destDir, 'agents')) { + console.log(` ${green}✓${reset} Installed agents`); + } else { + failures.push('agents'); } - } - console.log(` ${dim}↳${reset} Skipping agents (minimal install — run \`gsd update\` without \`--minimal\` to add full surface)`); - } else if (fs.existsSync(agentsSrc)) { - fs.mkdirSync(agentsDest, { recursive: true }); - - // Copy new agents - const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true }); - for (const entry of agentEntries) { - if (entry.isFile() && entry.name.endsWith('.md')) { - const agentSourcePath = path.join(agentsSrc, entry.name); - let content = fs.readFileSync(agentSourcePath, 'utf8'); - // #2995 (epic #1671 Phase 6.4): strip `` markers BEFORE - // the path-rewrite regexes below, so a rewrite can never reach inside a - // marker attribute. No-op (byte-identical) for an unmarked agent. - content = composeWorkflow(content, { sourcePath: agentSourcePath }); - // Replace ~/.claude/ and $HOME/.claude/ as they are the source of truth in the repo - const dirRegex = /~\/\.claude\//g; - const homeDirRegex = /\$HOME\/\.claude\//g; - const bareDirRegex = /~\/\.claude\b/g; - const bareHomeDirRegex = /\$HOME\/\.claude\b/g; - const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); - // #2096: `&& !isAntigravity` dropped — antigravity is in - // _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already - // unreachable for it; the path-rewrite skip for antigravity now lives - // in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite). - // #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in - // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole - // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it; - // isCopilot was therefore always false here, making the guard a no-op. - content = content.replace(dirRegex, pathPrefix); - content = content.replace(homeDirRegex, pathPrefix); - content = content.replace(bareDirRegex, normalizedPathPrefix); - content = content.replace(bareHomeDirRegex, normalizedPathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - // Convert frontmatter for runtime compatibility (agents need different handling) - if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') { - // Resolve per-agent model for OpenCode agents. - // Precedence: model_overrides[agent] > model_profile_overrides.opencode. > omit. - // model_overrides (#2256): explicit per-agent override, highest precedence. - // model_profile_overrides (#2794): tier-based runtime resolver, same parity as Codex. - const _ocAgentName = entry.name.replace(/\.md$/, ''); - const _ocModelOverrides = readGsdEffectiveModelOverrides(targetDir); - let _ocModelOverride = _ocModelOverrides?.[_ocAgentName] || null; - if (!_ocModelOverride) { - // Fall back to tier-based resolution via model_profile_overrides.opencode.. - const _ocRuntimeResolver = readGsdRuntimeProfileResolver(targetDir); - if (_ocRuntimeResolver) { - const _ocEntry = _ocRuntimeResolver.resolve(_ocAgentName); - if (_ocEntry?.model) { - _ocModelOverride = _ocEntry.model; - } - } - } - content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride }); - } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') { - // Resolve per-agent model for Kilo agents (#2093 UPGRADE 2; Kilo is an - // OpenCode fork with the same static-frontmatter model constraint). - // Precedence: model_overrides[agent] > model_profile_overrides.kilo. > omit. - // model_overrides (#2256): explicit per-agent override, highest precedence. - // model_profile_overrides (#2794): tier-based runtime resolver, same parity as OpenCode. - const _kiloAgentName = entry.name.replace(/\.md$/, ''); - const _kiloModelOverrides = readGsdEffectiveModelOverrides(targetDir); - let _kiloModelOverride = _kiloModelOverrides?.[_kiloAgentName] || null; - if (!_kiloModelOverride) { - // Fall back to tier-based resolution via model_profile_overrides.kilo.. - const _kiloRuntimeResolver = readGsdRuntimeProfileResolver(targetDir); - if (_kiloRuntimeResolver) { - const _kiloEntry = _kiloRuntimeResolver.resolve(_kiloAgentName); - if (_kiloEntry?.model) { - _kiloModelOverride = _kiloEntry.model; - } - } - } - content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride }); - } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') { - content = convertClaudeAgentToCodexAgent(content); - // #2099: `else if (isCopilot)` arm dropped — copilot is unreachable - // here (see the isCopilot-guard-drop comment above); its content - // conversion is applied pre-staging via the descriptor's - // artifactLayout.converter (runtime-artifact-layout.cts), independent - // of this legacy loop. - // #2100: `else if (isWindsurf)` arm dropped — windsurf is ALSO in - // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9575 above), so this whole - // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it; - // isWindsurf was therefore always false here, making the arm dead. - // Its content conversion is applied pre-staging via the descriptor's - // artifactLayout.converter (convertClaudeAgentToWindsurfAgent), - // independent of this legacy loop. - } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') { - // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into - // hostBehaviors.frontmatterDialect === 'cline'. - content = convertClaudeAgentToClineAgent(content); - } else if (_hostBehaviors(runtime).brandingRewrites) { - // Descriptor-driven (ADR-1239 / #2092): folded from separate - // `isQwen` / hermes-hardcoded branches into a single read of - // runtime.hostBehaviors.brandingRewrites (qwen -> QWEN.md/Qwen - // Code/.qwen/, hermes -> HERMES.md/Hermes Agent/.hermes/). - const _b = _hostBehaviors(runtime).brandingRewrites; - content = content.replace(/CLAUDE\.md/g, _b['CLAUDE.md']); - content = content.replace(/\bClaude Code\b/g, _b['Claude Code']); - content = content.replace(/\.claude\//g, _b['.claude/']); - } - // #443 — Inject `effort:` into the Claude .md frontmatter ONLY. - // OpenCode/Qwen/Hermes also produce .md files but break on - // unknown frontmatter keys (the repo bans skills:/permissionMode: for - // the same reason — see tests/agent-frontmatter.test.cjs). - // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536). - // Injection is per-runtime at install time because the canonical source - // agents/*.md must stay runtime-safe (no effort: key in source). - if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) { - const _effortCfg = readGsdEffectiveEffortConfig(targetDir); - const _agentName = entry.name.replace(/\.md$/, ''); - const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName); - // #3533 (10d): 'inherit' means the effort: key must NOT exist — - // Claude Code then follows the session effort. The canonical source - // agents carry no effort key, so skipping injection is the whole job. - if (_universalEffort !== 'inherit') { - const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value; - content = injectEffortFrontmatter(content, _renderedEffort); - } - const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName]; - if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools); - } - // #3677 — normalize retired `/gsd:` colon refs in the agent body - // to the canonical hyphen form `/gsd-` for hyphen-`name:` - // runtimes (claude / qwen / hermes). Self-converting and - // colon-canonical runtimes are skipped by the predicate — see - // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the - // SKILL.md-body fix shipped via #3629. - content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames()); - // #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is - // unreachable here (see the isCopilot-guard-drop comment above), so - // the ternary always evaluated to entry.name in practice; its - // .agent.md suffix is applied by the descriptor-driven fold in - // src/install-engine.cts (hostBehaviors.agentFileExtension). - const destName = entry.name; - fs.writeFileSync(path.join(agentsDest, destName), content); - } - } - if (verifyInstalled(agentsDest, 'agents')) { - console.log(` ${green}✓${reset} Installed agents`); + } else if (_resolvedProfile.skills !== '*') { + console.log(` ${dim}↳${reset} Skipping agents (${_resolvedProfile.name} profile excludes all agents — run \`gsd update\` with a broader profile to add them)`); } else { - failures.push('agents'); + console.log(` ${dim}↳${reset} No agents kind declared for ${runtime} at this scope`); } } + // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections — + // NOT agents-directory materialization (design doc "Deliberately not in + // scope"), so this stays independent of the agents/ write above. Without + // stripping these on a full → minimal reinstall, the runtime would keep + // advertising the old full agent surface even though the descriptor-driven + // write above already skipped writing the .md files for a minimal-tier + // resolvedProfile. Reuse the same helper that powers `--uninstall`. + if (isMinimalMode(_effectiveInstallMode) && _hostBehaviors(runtime).tomlConfigInstall) { + const codexConfigPath = path.join(targetDir, 'config.toml'); + if (fs.existsSync(codexConfigPath)) { + const existing = fs.readFileSync(codexConfigPath, 'utf8'); + const cleaned = stripGsdFromCodexConfig(existing); + if (cleaned === null) { + fs.unlinkSync(codexConfigPath); + } else if (cleaned !== existing) { + fs.writeFileSync(codexConfigPath, cleaned); + } + } + } + + // agentsSrc is declared as `let` before the enclosing try block (not const) + // so it is accessible by installCodexConfig() in the Codex config section + // below — that function reads RAW source agents/*.md (not the + // descriptor-staged output above) to build Codex's per-agent config.toml + // sidecar files, a separate writer this migration deliberately does not + // touch (design doc: "Codex's config.toml [agents.gsd-*] strip... is not + // agents-directory materialization"). + agentsSrc = _stageAgents(path.join(src, 'agents')); + // Copy CHANGELOG.md const changelogSrc = path.join(src, 'CHANGELOG.md'); const changelogDest = path.join(targetDir, 'gsd-core', 'CHANGELOG.md'); @@ -13856,11 +13700,15 @@ module.exports = { saveLocalPatches, reportLocalPatches, validateHookFields, - preserveUserArtifacts, - restoreUserArtifacts, migrateLegacyDevPreferencesToSkill, populatePristineDir, USER_OWNED_ARTIFACTS, + stageUserArtifacts, + restoreStagedUserArtifacts, + discardStagedUserArtifacts, + recoverOrphanedUserArtifacts, + _resolveUserArtifactStagingRoot, + _tryResolveUserArtifactStagingRoot, finishInstall, homePathCoveredByRc, homePathCoveredByFishConfig, diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index a7a90163a..bd11e37db 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -28,6 +28,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], "local": [ diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index aaa1c2a65..f8f13fc59 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -28,9 +28,26 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClineSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" + } + ] }, "triggerPrecedence": ["skills", "commands"], "commandStyle": "slash-hyphen", diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index 658ed4ffe..a0d3b58b7 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -29,6 +29,14 @@ "recursive": false, "converter": "convertClaudeCommandToCodexSkill", "home": ".agents" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ], "local": [ @@ -39,6 +47,14 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodexSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ] }, diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index d745e5108..c7d38bb5f 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -28,6 +28,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ], "local": [ @@ -38,6 +46,14 @@ "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ] }, diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index 1d2c3d9c1..95c380200 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -43,6 +43,14 @@ "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ], "local": [ @@ -61,6 +69,14 @@ "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ] }, diff --git a/capabilities/kimi-code/capability.json b/capabilities/kimi-code/capability.json index b91823e7a..a8a37d766 100644 --- a/capabilities/kimi-code/capability.json +++ b/capabilities/kimi-code/capability.json @@ -32,9 +32,26 @@ "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToKimiCodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null + } + ] }, "triggerPrecedence": ["skills", "commands"], "commandStyle": "slash-hyphen", diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index a53008729..85a4a9083 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -38,6 +38,14 @@ "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ], "local": [ @@ -56,6 +64,14 @@ "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ] }, diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index fda6262f3..93d91e3a7 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -399,6 +399,7 @@ "install-effort-resolver.cjs", "install-engine.cjs", "install-fs-adapter.cjs", + "install-model-override-resolver.cjs", "install-profiles.cjs", "install-scope.cjs", "install-shadow-report.cjs", @@ -509,6 +510,7 @@ "ui-safety-gate.cjs", "unusable-input.cjs", "update-context.cjs", + "user-artifact-staging.cjs", "validate-command-router.cjs", "validate.cjs", "vendor/re2js.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index ef9f3bdbc..3200de86d 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -517,6 +517,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) | | `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` | | `install-fs-adapter.cjs` | Install Fs Adapter — narrow, enumerated fs seam for the `installRuntimeArtifacts` call tree (#2874, epic #2866 Phase 5, ADR-58's never-landed `cleanup` rollout step); a single ambient adapter (real fs in production, an injectable fake in tests) is swapped for the duration of one synchronous install via `withInstallFs`, extending the `deps` bag precedent already established by Runtime Artifact Install Plan Module rather than threading a new parameter through every call site; routes destination IO only — package-source lookups (`findInstallSourceRoot`, `findAgentsSourceRoot`, `readGsdCommandNames`) are deliberately unrouted, by design, not by omission | +| `install-model-override-resolver.cjs` | Install-time per-agent model-override resolution (#2256 / #2794) — `model_overrides[agent]` > `model_profile_overrides..` > omit, extracted from `bin/install.js`'s inline agent-staging loop (#2875 Part 2 / J8), which duplicated this exact precedence chain across two runtime branches (OpenCode/Kilo); mirrors `install-effort-resolver.cjs`'s existing extraction precedent so the descriptor-driven agents pipeline and `bin/install.js`'s own callers resolve through the SAME single source of truth | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | | `install-scope.cjs` | Install Scope Module — `resolveScope({id,runtime,...})` resolves the `'global'\|'local'` install-scope axis into `{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}`, composing `resolveConfigHomeFromDescriptor` (`runtime-homes.cjs`) rather than modifying it (#2870, ADR-2866) | | `install-shadow-report.cjs` | Cross-Scope Shadow Report Module (#2873, epic #2866 Phase 4a) — read-only projection over `installed-surface-resolver.cjs`'s `resolveInstalledSurfaces`; `buildShadowReport(runtime, opts)` filters `resolveTriggerSurface`'s `shadowedBy` groups down to triggers whose underlying stem genuinely exists in BOTH scopes' own manifests (not merely the union), and `renderShadowReport` projects the typed IR into bounded, sanitized (`sanitizeForRender` strips ANSI/C0-C1/bidi overrides) operator-console lines; consumed by both the installer and the W028 health rule so install-time and `/gsd-health` report identically | @@ -610,6 +611,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `ui-safety-gate.cjs` | Shell-free word-boundary UI token detector (#3706, #3718); reads phase-section text from stdin, exits 0 (UI found) or 1 (no UI); also deployed to `gsd-core/bin/lib/` so the GSD installer ships it to `$RUNTIME_DIR` (#448) | | `ui-frontend-evidence.cjs` | Static frontend-evidence detector (compiled from `src/ui-frontend-evidence.cts`, gitignored) — plan-time structural corroboration for `computeUiPlanGate` (#3312): a `package.json` UI-framework dependency or a component-framework file (`*.tsx/*.jsx/*.vue/*.svelte`) in the tree, so a UI-token match on a hyphenated proper noun (e.g. repo `dashboard-financeiro`) cannot block planning in a repo with no frontend; mirrors the post-wave `computeUiSafetyGate` git-diff corroboration | | `update-context.cjs` | Pure install-context resolver for `/gsd-update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) | +| `user-artifact-staging.cjs` | Durable, on-disk staging for `USER_OWNED_ARTIFACTS` across the preserve → wipe → restore window (compiled from `src/user-artifact-staging.cts`, gitignored; #2875, epic #2866 Phase 6, ADR-3574) — closes #1874-F19 (in-memory-only preservation lost on a crash between wipe and restore); `recoverOrphanedUserArtifacts` is wired at the start of `bin/install.js`'s `install()` so an orphaned staged copy from a prior crashed run is recovered before the ordinary preserve step, closing the #1879-F15 inert-fix failure mode; exports `stageUserArtifacts`, `restoreStagedUserArtifacts`, `discardStagedUserArtifacts`, `recoverOrphanedUserArtifacts` | | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | | `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async | | `verification-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verification` | diff --git a/docs/adr/2866-install-surface-resolution.md b/docs/adr/2866-install-surface-resolution.md index 1416a945d..18f7db407 100644 --- a/docs/adr/2866-install-surface-resolution.md +++ b/docs/adr/2866-install-surface-resolution.md @@ -44,6 +44,38 @@ This correction is also why `windsurf`'s row above reads correctly without amend one), which is exactly the case Phase 2's test suite locks in as "windsurf must not report a shadow it does not have." +## Amendment (2026-08-17, #2875): `global=[skills]` described the descriptor, not the disk + +Phase 6 found that the claude row is wrong even as the *placement* fact the amendment above +preserved. **A `claude --global` install has always written `agents/gsd-*.md`.** It did so through +the inline agent-staging loop in `bin/install.js`, which was never scope-gated and never consulted +the descriptor — claude was not a member of the `_DESCRIPTOR_AGENTS_RUNTIMES` allow-list, so it fell +through to that loop on every install, at both scopes. + +So `global=[skills]` accurately described what claude's `capability.json` *declared*, and did not +describe what the installer actually wrote. Those two things had silently diverged, and this ADR — +along with the layout module's own golden tests — recorded the declaration as though it were the +outcome. + +Phase 6 closes the gap from the other side: the inline loop is deleted, the descriptor is +authoritative for `agents` on every runtime, and claude's descriptor now declares `agents` at global +scope. The row's true shape is therefore **`global=[skills, agents]`, `local=[commands, agents]`**. +On-disk bytes are unchanged — the golden install-tree fixtures did not move, which is the evidence +that the descriptor, not the installer, was the thing that was incomplete. + +**#2218 is again unaffected, for the reason the previous amendment established.** `agents` is not +trigger-bearing, so widening claude's global row to include it changes nothing about the +`commands`-versus-`skills` collision that #2218 *is*. Anyone re-reading this ADR after Phase 6 +should not infer a new shadowing case from the wider row. + +**The generalizable warning.** A descriptor that is merely *incomplete* is invisible: nothing fails, +because a separate code path is quietly doing the work the descriptor should have declared. It +surfaces only when something forces the two into agreement — here, deleting the code path. Reviews +that check "does the descriptor say the right thing?" cannot catch this; only "does anything install +artifacts this descriptor does not declare?" can. Three separate enumerations in Phase 6 were short +for a related reason, all recorded in [ADR-3574](3574-install-materialization-primitives.md)'s +amendment. + No decision in this ADR changes as a result — Phase 2's `resolveTriggerSurface` signature, the `triggerPrecedence` axis, and the phase map above were all designed against the corrected model. See `.gsd/phase/feat-2871-trigger-resolution/40-design.md` for the full analysis. diff --git a/docs/adr/3574-install-materialization-primitives.md b/docs/adr/3574-install-materialization-primitives.md index 24c39c150..97d18ca8a 100644 --- a/docs/adr/3574-install-materialization-primitives.md +++ b/docs/adr/3574-install-materialization-primitives.md @@ -165,3 +165,103 @@ the same name. The decision above rests on read code, not on those numbers. - Placement seam: [ADR-3660](3660-runtime-artifact-layout-module.md) · content seam: [ADR-1508](1508-runtime-artifact-conversion-module.md) · policy/adapter split: [ADR-58](58-runtime-install-policy-module.md) - The epic's own frame: [ADR-2866](2866-install-surface-resolution.md), which mandated that this module owe its own ADR - User-directory preservation this ADR protects: #2973, #3664 + +## Amendment (2026-08-17, #2875): four factual claims corrected by implementation + +Implementing this ADR as Phase 6 disproved four of the statements it rests on. **The central +decision — §1, no single materializer — is unaffected and stands**; the divergence table that +justified it was measured correctly. What follows corrects the surrounding claims, because a reader +who acts on them will be misled. + +This is the same failure mode the ADR itself warns about in "A note on the evidence": conclusions +reached by reading code without executing it. Three of the four corrections below are cases where +inspection produced a confident, wrong answer. + +### 1. §Decision 3 is void — the retired-kind prune already had a single owner + +The ADR says the prune "is extracted" and is "already called by both `installRuntimeArtifacts` and +`applySurface`". Measured: `pruneRetiredRuntimeArtifacts` already lives alone in +`src/retired-artifact-cleanup.cts`, already exports a single function, already routes every fs call +through `installFs()`, and has **three** callers — `installRuntimeArtifacts`, +`uninstallRuntimeArtifacts` and `applySurface`. + +There was nothing to extract. **No refactor was invented to satisfy this decision.** A future reader +should treat §3 as already-satisfied, not as outstanding work. + +### 2. The `agents`-bypass runtime set was wrong, and §Decision 4 was the *hardest* part, not the easiest + +The ADR states the inline dispatch "survives only for codex, cline, hermes and generic runtimes", +and calls closing it "the part of Phase 6 whose evidence survived scrutiny intact". + +Both are wrong. `_DESCRIPTOR_AGENTS_RUNTIMES` (`bin/install.js`) held ten runtimes; every other +runtime reached the inline loop — **seven** of them: claude (the flagship), cline, codex, hermes, +kilo, opencode and kimi-code. (`pi` is excluded separately by its `pluginOnlyInstall` branch.) + +> **Even this correction undercounted.** It originally said six. `kimi-code` was found only when a +> golden install-tree fixture went red mid-implementation — not by any amount of reading. That is +> the third time this phase's enumeration was short (four call sites → seven; six runtimes → +> seven), and every miss shares one cause: counting by *symbol* or *set membership* when the thing +> that matters is a *behavior*. Fixtures and executed tests found what inspection did not. + +The set and the loop are both **gone** as of this phase; the descriptor is authoritative for +`agents` on every runtime, so there is no longer an allow-list to join. + +Worse, closing the bypass could not be done "on its own terms". It required **three new pieces of +descriptor contract**, because the descriptor pipeline had no per-agent resolution context: + +| gap | consumer | +|---|---| +| a frontmatter-extensions step (`effort`, `disallowedTools`) | claude | +| per-agent model-override resolution threaded to the converter | kilo, opencode | +| a named branding converter (the *data* was already declared; the converter was not) | hermes | + +Every one of those failed **silently** if migrated without the contract work — wrong bytes, nothing +thrown. §Decision 4's framing as independent and low-risk should not be relied on. + +### 3. Three of the four blockers in `runtime-artifact-layout.cts` were already stale + +The ADR treats that comment's blocker list as current. Measured, only one was: + +| blocker | status | +|---|---| +| Copilot's `.agent.md` filename rename | stale — #2099 dropped the ternary; the suffix comes from `hostBehaviors.agentFileExtension` | +| cross-cutting path-prefix rewrite + attribution | stale — `stageAgentsForRuntimeWithConverter` already does both when `agentCtx` is present | +| stale-file cleanup | stale — `_removeGsdEntries` prunes more broadly than the loop's extension-gated check | +| config-reading steps | **real** — and it was the entire remaining gap (see §2 above) | + +### 4. F19 is seven call sites, not four — and the helper was the wrong thing to search for + +The ADR names four call sites, found by locating callers of `preserveUserArtifacts`. There are +**seven**. Three of them never call the helper at all; they open-code the same +`readFileSync` → wipe → `writeFileSync`. + +**The generalizable lesson: the defect is the pattern "user data held only in memory across a +wipe", not the helper.** Searching for callers of the helper under-counts by construction. The three +extra sites were found by sweeping for the pattern — a read shortly before a wipe and a write +shortly after. + +The ADR also understates the severity. The worst site is the mainline install path, where the +window spans the **entire `gsd-core` tree rebuild** inside `copyWithPathReplacement`, not a single +`rmSync`. Any interruption of a normal install destroys the file. + +### 5. Resolved: `USER_OWNED_ARTIFACTS` + +"What this ADR does not decide" records its membership as unconfirmable. It is confirmed: +`src/install-engine.cts` defines it as exactly **`['USER-PROFILE.md']`**, with a docblock recording +the invariant that a file is either manifest-tracked distribution or a preserved user artifact, +never both (#2771). `dev-preferences.md` is preserved at other sites by explicit name. That open +question is closed. + +### 6. `copyPreservingSymlink` could not be reused verbatim + +§Decision 2 directs reusing it, and that is still the right primitive for the reason given (it never +dereferences a symlink). But it used raw `fs` for all five of its calls, while its new caller sits on +the install path Phase 5 routed through an injectable seam. Verbatim reuse would have punched a hole +through that seam — the partial-adapter trap `install-fs-adapter.cts` documents. It was routed +through `installFs()` as part of the extraction; its existing migration caller is unaffected, since +the ambient default resolves to real fs. + +**A caution for anyone extending this module:** that same fall-through is a live hazard. A missing +method on an injected adapter does not fail loudly — it silently reaches the real filesystem. Adding +a new `installFs()` call to a routed path without extending every adapter is a real-IO bug that +passes typecheck. diff --git a/docs/how-to/add-or-update-a-host-integration.md b/docs/how-to/add-or-update-a-host-integration.md index eddc1823a..e0f8d481a 100644 --- a/docs/how-to/add-or-update-a-host-integration.md +++ b/docs/how-to/add-or-update-a-host-integration.md @@ -158,10 +158,28 @@ uninstall side-effect branches in `bin/install.js` (→ `resolveInstallPlan(runtime).installSurface === 'copilot-instructions'`, already a live descriptor field elsewhere in the same file), and two `skipSharedHooksInstall` gates (→ `hostBehaviors.skipSharedHooksInstall: true`). A dead legacy agent-converter dispatch arm — unreachable -because copilot is a member of `_DESCRIPTOR_AGENTS_RUNTIMES` — was deleted outright rather than re-gated, -mirroring step 6's guard: `tests/declarative-reference-copilot.test.cjs` source-greps both files for the -retired `isCopilot` reads. See the `copilot` section of the reference matrix for the full EoS migration -note, including the two upgrades (multi-event hook bus; negotiated `dispatch.background`) this PR adds. +because copilot was a member of the then-existing `_DESCRIPTOR_AGENTS_RUNTIMES` allow-list — was deleted +outright rather than re-gated, mirroring step 6's guard: +`tests/declarative-reference-copilot.test.cjs` source-greps both files for the retired `isCopilot` reads. +See the `copilot` section of the reference matrix for the full EoS migration note, including the two +upgrades (multi-event hook bus; negotiated `dispatch.background`) this PR adds. + +> **`_DESCRIPTOR_AGENTS_RUNTIMES` no longer exists (#2875).** It was an allow-list naming the runtimes +> whose `agents` came from the descriptor; everything absent from it fell through to an inline +> `_hostBehaviors()` dispatch loop in `bin/install.js`. That loop and the set are both gone — the +> descriptor is now authoritative for `agents` on **every** runtime, so there is no longer an +> opt-in list to join. Declare an `agents` entry under `artifactLayout` and it is installed. +> +> If your host needs a per-agent transform the descriptor cannot yet express, extend the pipeline +> rather than reintroducing an inline branch. The three extension points added when the loop was +> removed are the pattern to follow: `hostBehaviors.agentFrontmatterExtensions` for injected +> frontmatter keys, per-agent model-override resolution threaded through the converter's options, +> and a named converter driven by descriptor data (hermes's branding rewrites are declared in +> `capability.json`, not hardcoded). All three exist because the descriptor pipeline lacked one +> thing: per-agent resolution context (`targetDir` + `agentName`). +> +> Declaring an `agents` entry also takes effect on the **surface** path (`/gsd-surface --materialize`) +> immediately, not only on install — the two paths are intentionally converged. --- diff --git a/docs/how-to/recover-and-troubleshoot.md b/docs/how-to/recover-and-troubleshoot.md index ecb95cedc..9c3dc6c73 100644 --- a/docs/how-to/recover-and-troubleshoot.md +++ b/docs/how-to/recover-and-troubleshoot.md @@ -265,6 +265,33 @@ npx @opengsd/gsd-core@latest --claude --local For runtime-specific install paths and troubleshooting, see [Install on your runtime](install-on-your-runtime.md). +### If an install or uninstall was interrupted + +Nothing to do — finish the interrupted command by running it again. + +GSD deletes and rebuilds whole directories while installing, and some files in them are yours +rather than GSD's: `USER-PROFILE.md` (written by `/gsd-profile-user`) and `dev-preferences.md`. +Before anything is deleted, GSD copies those to a staging area under your runtime's config +directory, at `.gsd-staging/user-artifacts/`. The copy is committed to disk before the delete +begins, so pressing Ctrl+C, a crash, or a machine losing power cannot leave +you without them. + +The next install or uninstall looks for staged copies left behind by an interrupted run and +restores them before doing anything else. It will not overwrite a file that is already there — a +file present on disk was never lost — and it leaves alone any staging belonging to another install +still running. + +To confirm your profile came back: + +```bash +ls ~/.claude/gsd-core/USER-PROFILE.md +``` + +If the file is missing but `.gsd-staging/user-artifacts/` still contains an entry, run the install +again; recovery happens at the start of the next run, not in the background. If you are curious +what is staged, each entry holds a `record.json` naming the directory it came from and the files it +holds — those are ordinary files you can inspect or copy out by hand. + ### If Codex agents fail to spawn with a 400 about an unsupported model Symptom — a typed agent (`gsd-planner`, `gsd-executor`, …) fails to start and the whole diff --git a/eslint.config.mjs b/eslint.config.mjs index e0abb340e..a587ffda8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -76,10 +76,16 @@ export default tseslint.config( 'gsd-core/bin/lib/handshake-serialized.cjs', 'gsd-core/bin/lib/host-integration-sdk.cjs', 'gsd-core/bin/lib/install-effort-resolver.cjs', + // #2875 Part 2 (epic #2866 Phase 6): tsc-generated runtime artifact — + // lint the src/install-model-override-resolver.cts source, not this. + 'gsd-core/bin/lib/install-model-override-resolver.cjs', 'gsd-core/bin/lib/install-engine.cjs', // #2874 (epic #2866 Phase 5): tsc-generated runtime artifact — lint the // src/install-fs-adapter.cts source, not this. 'gsd-core/bin/lib/install-fs-adapter.cjs', + // #2875 (epic #2866 Phase 6): tsc-generated runtime artifact — lint the + // src/user-artifact-staging.cts source, not this. + 'gsd-core/bin/lib/user-artifact-staging.cjs', 'gsd-core/bin/lib/commonjs-marker.cjs', 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/capability-source.cjs', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 20d2a6c66..0982c6af5 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -511,6 +511,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], "local": [ @@ -753,9 +761,26 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClineSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" + } + ] }, "triggerPrecedence": [ "skills", @@ -1049,6 +1074,14 @@ const capabilities = { "recursive": false, "converter": "convertClaudeCommandToCodexSkill", "home": ".agents" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ], "local": [ @@ -1059,6 +1092,14 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodexSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ] }, @@ -1732,6 +1773,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ], "local": [ @@ -1742,6 +1791,14 @@ const capabilities = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ] }, @@ -1894,6 +1951,14 @@ const capabilities = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ], "local": [ @@ -1912,6 +1977,14 @@ const capabilities = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ] }, @@ -2098,9 +2171,26 @@ const capabilities = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToKimiCodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null + } + ] }, "triggerPrecedence": [ "skills", @@ -2638,6 +2728,14 @@ const capabilities = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ], "local": [ @@ -2656,6 +2754,14 @@ const capabilities = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ] }, @@ -5230,6 +5336,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], "local": [ @@ -5384,9 +5498,26 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClineSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToClineAgent" + } + ] }, "triggerPrecedence": [ "skills", @@ -5577,6 +5708,14 @@ const runtimes = { "recursive": false, "converter": "convertClaudeCommandToCodexSkill", "home": ".agents" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ], "local": [ @@ -5587,6 +5726,14 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToCodexSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToCodexAgent" } ] }, @@ -5967,6 +6114,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ], "local": [ @@ -5977,6 +6132,14 @@ const runtimes = { "nesting": "nested", "recursive": false, "converter": "convertClaudeCommandToClaudeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeAgentToHermesAgent" } ] }, @@ -6077,6 +6240,14 @@ const runtimes = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ], "local": [ @@ -6095,6 +6266,14 @@ const runtimes = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToKiloSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToKiloFrontmatter" } ] }, @@ -6281,9 +6460,26 @@ const runtimes = { "nesting": "flat", "recursive": false, "converter": "convertClaudeCommandToKimiCodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null } ], - "local": [] + "local": [ + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": null + } + ] }, "triggerPrecedence": [ "skills", @@ -6423,6 +6619,14 @@ const runtimes = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ], "local": [ @@ -6441,6 +6645,14 @@ const runtimes = { "nesting": "flat", "recursive": true, "converter": "convertClaudeCommandToOpencodeSkill" + }, + { + "kind": "agents", + "destSubpath": "agents", + "prefix": "gsd-", + "nesting": "flat", + "recursive": false, + "converter": "convertClaudeToOpencodeFrontmatter" } ] }, diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index b00396bb4..b6e187aa4 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -730,6 +730,13 @@ const VALID_CONVERTER_NAMES = new Set([ // #3384 — ZCode agents are Claude-shaped but its dispatcher treats mcp__* tools // grants as required MCP servers; this converter strips them at install time. 'convertClaudeAgentToZcodeAgent', + // #2875 Part 2 (the agents-bypass closure) — data-driven Hermes branding + // converter (reads hostBehaviors.brandingRewrites rather than a hardcode), + // and the kilo/opencode agent converters (shared with those runtimes' + // commands-kind entries, options-bag signature `(content, {isAgent, modelOverride})`). + 'convertClaudeAgentToHermesAgent', + 'convertClaudeToKiloFrontmatter', + 'convertClaudeToOpencodeFrontmatter', ]); // C3: Validate role:runtime body diff --git a/src/install-effort-resolver.cts b/src/install-effort-resolver.cts index 2439ddde9..736362fa4 100644 --- a/src/install-effort-resolver.cts +++ b/src/install-effort-resolver.cts @@ -15,11 +15,28 @@ * * Pure with respect to config: `readGsdEffectiveEffortConfig` performs the config * reads; `resolveInstallTimeEffort` is pure given a pre-merged effort object. + * + * #2875 defect fix: this module sits on the `installRuntimeArtifacts` call + * tree (reached both directly from `runtime-artifact-conversion.cts`'s + * effort-injection rewrite pass, and transitively via + * `install-model-override-resolver.cts`'s `_readGsdConfigFile` reuse) — every + * DESTINATION/config fs touch below routes through `installFs()` + * (install-fs-adapter.cts), matching `retired-artifact-cleanup.cts` / + * `user-artifact-staging.cts`'s existing precedent. `config-defaults.manifest.json` + * (`_getGsdEffortCatalog`) stays on raw `node:fs`, deliberately: it is + * PACKAGE-SOURCE (ships under `gsd-core/bin/shared/`, resolved from + * `__dirname`, never the install destination), the same class of read + * `install-fs-adapter.cts`'s module doc documents as "DELIBERATELY NOT + * ROUTED" for `findInstallSourceRoot`/`readGsdCommandNames`. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module +import installFsAdapter = require('./install-fs-adapter.cjs'); +const { installFs } = installFsAdapter; + // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module import modelResolver = require('./model-resolver.cjs'); const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver as { EFFORT_SET: Set }; @@ -41,10 +58,10 @@ interface EffortConfig { * mask broken configs (review finding #5). */ function _readGsdConfigFile(absPath: string, label: string): Record | null { - if (!fs.existsSync(absPath)) return null; + if (!installFs().existsSync(absPath)) return null; let raw: string; try { - raw = fs.readFileSync(absPath, 'utf-8'); + raw = installFs().readFileSync(absPath, 'utf-8'); } catch (err) { process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${(err as Error).message}\n`); return null; @@ -118,6 +135,34 @@ function _getGsdEffortCatalog(): EffortCatalog { return _gsdEffortCatalogCache; } +/** + * #2875 defect fix (Generative Fix Divergence — the exact class this module + * exists to remove): the upward walk from a runtime install root looking for + * `.planning/config.json`, capped at 8 ancestor levels, was duplicated + * verbatim three times — once here, and twice more in + * `install-model-override-resolver.cts` (`readGsdEffectiveModelOverrides`, + * `readGsdRuntimeProfileResolver`) after that module was extracted FROM this + * one specifically to stop duplicating shared install-time config logic. + * Single-sourced here so the cap and the walk semantics (depth 0 = targetDir + * itself, depth 7 = the last checked ancestor, 8 levels up is never reached) + * can only diverge if this function changes. + * + * @param targetDir Runtime install root to start the walk from. + * @returns the first `.planning/config.json` found walking upward from + * `targetDir` (inclusive) through up to 8 ancestor levels, or `null`. + */ +function _findAncestorGsdConfigPath(targetDir: string): string | null { + let probeDir = path.resolve(targetDir); + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(probeDir, '.planning', 'config.json'); + if (installFs().existsSync(candidate)) return candidate; + const parent = path.dirname(probeDir); + if (parent === probeDir) break; + probeDir = parent; + } + return null; +} + /** * #443 — Read the merged `effort` config block for install-time effort resolution. * @@ -139,17 +184,8 @@ function readGsdEffectiveEffortConfig(targetDir: string | null = null): EffortCo let projectConfig: Record | null = null; if (targetDir) { - let probeDir = path.resolve(targetDir); - for (let depth = 0; depth < 8; depth += 1) { - const candidate = path.join(probeDir, '.planning', 'config.json'); - if (fs.existsSync(candidate)) { - projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); - break; - } - const parent = path.dirname(probeDir); - if (parent === probeDir) break; - probeDir = parent; - } + const candidate = _findAncestorGsdConfigPath(targetDir); + if (candidate) projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); } const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort)) @@ -251,4 +287,5 @@ export = { resolveInstallTimeEffort, _getGsdEffortCatalog, _readGsdConfigFile, + _findAncestorGsdConfigPath, }; diff --git a/src/install-engine.cts b/src/install-engine.cts index e36c8ca93..527f4dea6 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -40,6 +40,10 @@ import { ensureCommonJsMarker } from './commonjs-marker.cjs'; // why this is an ambient swap rather than a threaded `deps` parameter. import installFsAdapter = require('./install-fs-adapter.cjs'); const { installFs, withInstallFs } = installFsAdapter; +// #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS +// across the preserve -> wipe -> restore window (#1874-F19). See +// user-artifact-staging.cts's module doc. +import userArtifactStaging = require('./user-artifact-staging.cjs'); // #2870: InstallScope is owned by install-scope.cts, not re-declared here. // `isGlobalScope` centralizes the `scope === 'global'` boolean projection // this module's two remaining re-derivation sites need (see the @@ -78,8 +82,9 @@ type ResolveAttribution = (runtime: string) => any; * * Invariant: a file is either distribution (manifest-tracked, diff'd against * manifest) or user artifact (preserved across installs, never diff'd). Never - * both. Both preserveUserArtifacts call sites and writeManifest must agree on - * this list, which is why it lives here as a single constant. + * both. Both the user-artifact-staging.cts call sites (#2875) and + * writeManifest must agree on this list, which is why it lives here as a + * single constant. * * Paths are relative to the gsd-core/ directory. */ @@ -154,46 +159,6 @@ const SKILLS_CONVERTER_REGISTRY: Record { - const saved = new Map(); - for (const name of fileNames) { - const fullPath = path.join(destDir, name); - if (installFs().existsSync(fullPath)) { - try { - saved.set(name, installFs().readFileSync(fullPath, 'utf8')); - } catch { /* skip unreadable files */ } - } - } - return saved; -} - -/** - * Restore user-generated files saved by preserveUserArtifacts after a wipe. - * - * @param destDir - Directory that was wiped and recreated - * @param saved - Map returned by preserveUserArtifacts - */ -function restoreUserArtifacts(destDir: string, saved: Map): void { - for (const [name, content] of saved) { - const fullPath = path.join(destDir, name); - try { - installFs().mkdirSync(path.dirname(fullPath), { recursive: true }); - installFs().writeFileSync(fullPath, content, 'utf8'); - } catch { /* skip unwritable paths */ } - } -} - // --------------------------------------------------------------------------- // Symlink-escape guard // --------------------------------------------------------------------------- @@ -229,6 +194,23 @@ function isSymlinkedDestOptIn(): boolean { return v === '1' || v === 'true'; } +/** + * `lstatSync`, never following a symlink, returning `null` instead of + * throwing when `p` does not exist AT ALL (not even as a dangling symlink). + * Unlike `existsSync` (which follows symlinks and reports `false` for a + * dangling one), this correctly distinguishes "nothing here" from "a + * symlink is here, even if its target is missing" — see + * `hasExistingSymlinkBetween`'s own doc comment for why that distinction is + * security-load-bearing. + */ +function tryLstat(p: string): { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean } | null { + try { + return installFs().lstatSync(p); + } catch { + return null; + } +} + /** * Returns true if any path component between `root` and `fullPath` is a * symbolic link that would redirect writes outside the install root in a way @@ -280,8 +262,24 @@ function hasExistingSymlinkBetween( // circular back-reference to root from a path that descends from a resolved // root. So under opt-in, just follow the root symlink and continue the walk. // Default behavior (no opt-in) preserves the pre-#2393 refuse. + // #2875 defect fix: `existsSync` FOLLOWS symlinks and returns `false` for a + // DANGLING symlink (one whose target does not exist) — so the pre-fix + // `existsSync(cursor) && lstatSync(cursor).isSymbolicLink()` ordering used + // below (both here for `root` and in the per-segment loop) silently + // treated a dangling symlink as "nothing here", never even reaching the + // `lstatSync` symlink check. That let a dangling symlink planted AT a + // write destination — e.g. `/USER-PROFILE.md -> + // /authorized_keys` — sail through this guard, after which the + // actual write (`copyFileSync` et al., which DOES follow symlinks) created + // attacker-controlled content outside the install root. `lstatSync` itself + // never follows a symlink and succeeds for a dangling one, so probing with + // it FIRST (falling back to "does not exist at all" only on ENOENT/similar) + // detects the dangling case correctly while preserving the exact same + // "cursor does not exist, stop walking" behavior for a path that truly has + // nothing there. let cursor = resolvedRoot; - if (installFs().existsSync(cursor) && installFs().lstatSync(cursor).isSymbolicLink()) { + const cursorLstat = tryLstat(cursor); + if (cursorLstat && cursorLstat.isSymbolicLink()) { if (!allowFollow) return true; try { cursor = installFs().realpathSync(cursor); @@ -296,8 +294,9 @@ function hasExistingSymlinkBetween( for (const segment of relative.split(path.sep)) { if (!segment) continue; cursor = path.join(cursor, segment); - if (!installFs().existsSync(cursor)) return false; - if (installFs().lstatSync(cursor).isSymbolicLink()) { + const segmentLstat = tryLstat(cursor); + if (!segmentLstat) return false; + if (segmentLstat.isSymbolicLink()) { if (!allowFollow) return true; // Opt-in active: follow the symlink. Refuse if the resolved target is the // install root itself (threat (b) — would let _removeGsdEntries wipe the @@ -330,6 +329,65 @@ function hasExistingSymlinkBetween( return false; } +// --------------------------------------------------------------------------- +// User-artifact staging root +// --------------------------------------------------------------------------- + +/** + * Resolve the durable staging root for `configDir` (#2875 / user-artifact- + * staging.cts), confined via the SAME `assertDestWithinConfigHome` gate every + * other write on this call tree uses, and refused via the SAME + * `hasExistingSymlinkBetween` guard `_copyStaged`/ + * `migrateLegacyDevPreferencesToSkill` already apply to their own writes + * (test-matrix E1/E4) — this module never reimplements either decision, only + * reuses them (user-artifact-staging.cts's own module doc, "Confinement"). + * + * Fixed location: `/.gsd-staging/user-artifacts/` — a sibling of + * every directory this phase's four call sites wipe, so staging survives all + * of them while staying inside configDir (40-design.md "Staging location"). + */ +function _resolveUserArtifactStagingRoot(configDir: string): string { + const stagingRoot = runtimeArtifactInstallPlan.assertDestWithinConfigHome( + configDir, + path.posix.join('.gsd-staging', 'user-artifacts'), + ); + if (hasExistingSymlinkBetween(path.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) { + throw new Error( + `_resolveUserArtifactStagingRoot: staging root "${stagingRoot}" contains a symlink the install root "${configDir}" does not trust — refusing to stage. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`, + ); + } + return stagingRoot; +} + +/** + * Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` (defect + * fix — a hostile/broken `.gsd-staging` path, or a symlinked configDir + * itself, e.g. nix-darwin/dotfiles-managed `~/.claude`, GSD_ALLOW_SYMLINKED_DEST's + * own population) must never brick the command it is called from. Before + * this fix `_resolveUserArtifactStagingRoot` was called UNGUARDED as the + * first statement of both `install()` and `uninstall()` (bin/install.js) — + * `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands, + * including uninstall, the remedy for the first problem. + * + * Returns `null` (never throws) when staging is unavailable, logging ONE + * warning naming the underlying cause. Every call site MUST treat `null` as + * "skip the staging-dependent step for this run" — the same "degrade, + * never throw" posture user-artifact-staging.cts's own recovery/restore + * functions already document (module doc "Failure posture"), extended to + * cover staging-ROOT resolution itself, not just the copy/restore that + * follows it. + */ +function _tryResolveUserArtifactStagingRoot(configDir: string): string | null { + try { + return _resolveUserArtifactStagingRoot(configDir); + } catch (err) { + console.warn( + ` [gsd] user-artifact staging unavailable for "${configDir}" (${(err as Error).message}) — proceeding without durable staging for this step.`, + ); + return null; + } +} + // --------------------------------------------------------------------------- // migrateLegacyDevPreferencesToSkill // --------------------------------------------------------------------------- @@ -349,13 +407,36 @@ function hasExistingSymlinkBetween( * migration so callers can log a one-line confirmation. * * @param targetDir - Resolved runtime config directory (e.g. ~/.claude) - * @param saved - Map returned by preserveUserArtifacts + * @param saved - Map of fileName -> content, built by the caller from a + * user-artifact-staging.cts staged batch's disk contents (#2875) — every + * call site reads this back AFTER its own wipe, never held in memory + * across it. * @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude') * @param scope - install scope * @returns true if a file was migrated, false otherwise */ -function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map, runtime?: string, scope: string = 'global'): boolean { - if (!saved || !saved.has('dev-preferences.md')) return false; +/** + * Resolve the `{ skillFile, installRoot }` `migrateLegacyDevPreferencesToSkill` + * would target for `(targetDir, runtime, scope)`, WITHOUT performing any + * write. Extracted (#2875 defect fix) purely as a resolution helper so a + * caller can determine whether migration is even POSSIBLE for this + * runtime/scope, and whether it is already SATISFIED (a skill file already + * present), BEFORE deciding whether discarding a staged legacy copy would + * lose the user's file — `migrateLegacyDevPreferencesToSkill`'s own boolean + * return conflates "no skills layout for this runtime" with "the write + * failed" with "already migrated": all three return `false` today, and + * changing that return SHAPE would also change bin/install.js's own + * `if (migrateLegacyDevPreferencesToSkill(...))` call site, which this + * module does not own. This helper changes nothing about + * `migrateLegacyDevPreferencesToSkill`'s own signature or behavior — it is + * now IMPLEMENTED in terms of this helper, so there is exactly one copy of + * the resolution logic, never two that could drift. + * + * @returns `{ skillFile, installRoot }`, or `null` if this runtime/scope has + * no skills layout to migrate into (mirrors `migrateLegacyDevPreferencesToSkill`'s + * own early return for that case). + */ +function _resolveDevPreferencesSkillTarget(targetDir: string, runtime?: string, scope: string = 'global'): { skillFile: string; installRoot: string } | null { let skillDir: string; // #2911: the actual install root the skill dir resolves under — defaults to // targetDir, but a skills-kind `home` override (e.g. Codex -> $HOME/.agents) @@ -366,7 +447,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map k.kind === 'skills'); - if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local) + if (!skillsKindEntry) return null; // runtime has no skills layout at this scope (e.g. cline local) const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; // #2911: same destination-root defect as _copyStaged/applySurface — honor // skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills @@ -379,8 +460,36 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map, runtime?: string, scope: string = 'global'): boolean { + if (!saved || !saved.has('dev-preferences.md')) return false; + const target = _resolveDevPreferencesSkillTarget(targetDir, runtime, scope); + if (!target) return false; // runtime has no skills layout at this scope (e.g. cline local) + const { skillFile, installRoot } = target; + const skillDir = path.dirname(skillFile); + // Security fix: `existsSync` FOLLOWS symlinks and reports `false` for a + // DANGLING one, so the prior `existsSync(skillFile)` check never even saw a + // dangling symlink planted AT the leaf (e.g. + // `/skills/gsd-dev-preferences/SKILL.md -> + // ~/.ssh/authorized_keys`) — it fell through past this "already migrated" + // bail, past the symlink-escape guard below (which only walks to `skillDir`, + // the parent DIRECTORY, and never lstats the leaf FILE itself), and into + // `writeFileSync`, which DOES follow symlinks and would have written + // attacker-chosen `saved` content to the symlink's target. `tryLstat` never + // follows a symlink and distinguishes "a real file is already here" (skip, + // same as before) from "a symlink (dangling or not) is planted here" + // (refuse — this is never a legitimate prior-migration state). + const skillFileLstat = tryLstat(skillFile); + if (skillFileLstat) { + if (skillFileLstat.isSymbolicLink()) { + throw new Error( + `migrateLegacyDevPreferencesToSkill: skillFile "${skillFile}" is a symlink — refusing to write dev-preferences.md content through it (would follow the link and write to its target).`, + ); + } + return false; // a real file is already there — already migrated, skip + } // Symlink-escape guard: reject if any path component between installRoot and // skillDir is a symlink that would redirect writes outside the install root. // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts. @@ -627,11 +736,28 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: // for migration. The actual migration call is deferred to after all layout cleanup so // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly // created skills/gsd-dev-preferences/ skill dir. - let savedLegacyArtifacts: Map | null = null; + let stagedLegacyArtifacts: ReturnType | null = null; if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) { if (installFs().existsSync(legacyCommandsGsd)) { - savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); - installFs().rmSync(legacyCommandsGsd, { recursive: true }); + // #2875: staging root resolved lazily, only when there is actually + // something to stage — reused below by every other call site sharing + // this configDir. + // #2875 defect fix: DEGRADE, never abort the whole install, when the + // staging root itself cannot be resolved (e.g. a hostile/broken + // `.gsd-staging` symlink) — skip this legacy-migration block entirely + // rather than wipe legacyCommandsGsd without a durable backup (module + // doc "Failure posture": a wipe having staged nothing is worse than no + // staging at all). The stale legacy dir is simply left in place for a + // future successful run. + const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir); + if (stagingRoot !== null) { + // #2875 (#1874-F19): staged DURABLY to disk before the wipe below, so a + // crash anywhere in this function — including the Hermes flat-skills + // wipe further down, previously inside the same in-memory-only window + // — survives via recoverOrphanedUserArtifacts on the next run. + stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot); + installFs().rmSync(legacyCommandsGsd, { recursive: true }); + } } } @@ -657,8 +783,90 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973). // Done after all layout cleanup so Hermes flat-dir removal does not delete the // newly created skill dir. No-op if skill file already exists. - if (savedLegacyArtifacts) { - migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + if (stagedLegacyArtifacts) { + // #2875: read the content back from the DISK-staged copy (fresh, after + // every wipe above has already run) rather than an in-memory value held + // across them. + // + // #2875 defect fix (readFileSync following a staged symlink): + // readFileSync ALWAYS follows a symlink — a staged artifact that is + // itself a symlink (module doc "Symlink safety", A4: staging never + // dereferences a symlink; a symlinked USER-artifact is recreated AS a + // symlink in the staging tree, not copied by content) would have its + // REFERENT's bytes read here and land in SKILL.md, violating this + // module's own "referent bytes never read" contract. A symlinked staged + // name is excluded from migration below and restored to its original + // location instead — migrating a symlink AS skill-file text content is + // not a coherent operation to begin with. + const savedLegacyArtifacts = new Map(); + const migratableNames: string[] = []; + for (const name of stagedLegacyArtifacts.names) { + const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name); + // #2875 defect fix (crash resilience — TOCTOU): a raw `lstatSync` throws + // if `stagedPath` has vanished between staging (above) and this read — + // e.g. a co-resident attacker on a shared machine racing the staging + // dir, the exact threat class this module's own "Confinement" doc + // already treats as live. Every sibling probe in this file (`tryLstat` + // itself, and its use at `skillFileLstat` above) already degrades + // rather than throws; do the same here — a vanished staged file is + // simply not migratable, matching A2's "absent, not staged, no throw" + // precedent in user-artifact-staging.cts. + const stagedLstat = tryLstat(stagedPath); + if (!stagedLstat || stagedLstat.isSymbolicLink()) continue; + savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8')); + migratableNames.push(name); + } + // #2875 defect fix (regression closed — was previously unguarded and + // BRICKED the command): migrateLegacyDevPreferencesToSkill correctly + // THROWS when it finds a planted/dangling symlink at the skill-file leaf + // (security fix — refusing to write through it is correct) but by this + // point legacyCommandsGsd has ALREADY been wiped (rmSync above) and + // stagedLegacyArtifacts is the only surviving copy. An unguarded throw + // here propagated straight out of installRuntimeArtifacts, aborting the + // whole install/uninstall WITHOUT ever reaching the restore-or-discard + // logic below — the staged batch was orphaned on disk and every retry + // hit the same throw again (same brick-the-command failure mode this + // module's "DEGRADE, never abort" posture, see + // _tryResolveUserArtifactStagingRoot above, already closed for a broken + // `.gsd-staging` path). Degrade identically: catch, warn once, and treat + // the batch as unmigrated so the restore branch below fires. + let migrated = false; + let migrationRefused = false; + try { + migrated = migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + } catch (err) { + console.warn( + ` [gsd] dev-preferences.md migration skipped for "${configDir}" (${(err as Error).message}) — restoring the legacy copy instead.`, + ); + migrationRefused = true; + } + // #2875 defect fix (call site 1 was a loss site): migrateLegacyDevPreferencesToSkill's + // boolean return conflates "migrated", "already satisfied" (skill file + // already present — safe to discard either way), and "cannot migrate" + // (no skills layout for this runtime, or the write itself failed — + // discarding here would silently lose the user's file, the exact loss + // this whole module exists to prevent). Distinguish via the resolved + // target's actual presence rather than trusting the boolean alone; a + // symlinked staged name (excluded from migration above) is treated the + // same way — never migrated, so it must not be silently discarded. + // + // #2875 defect fix (migrationRefused must short-circuit this to `false`, + // never fall through to the existsSync probe below): when + // migrateLegacyDevPreferencesToSkill refused because skillTarget.skillFile + // is a symlink, `existsSync` FOLLOWS it — a symlink pointing at some + // OTHER real file (not dangling) would read back `true` here and mark + // the batch "satisfied", discarding it without ever restoring it. Refusal + // is never satisfaction. + const skillTarget = migrationRefused ? null : _resolveDevPreferencesSkillTarget(configDir, runtime, scope); + const migrationSatisfied = !migrationRefused && (migrated || (skillTarget !== null && installFs().existsSync(skillTarget.skillFile))); + const nothingLeftUnmigrated = migrationSatisfied && migratableNames.length === stagedLegacyArtifacts.names.length; + if (!nothingLeftUnmigrated && stagedLegacyArtifacts.names.length > 0) { + // Put the whole batch back where it came from rather than losing + // whatever migration did not (or could not) account for. + installFs().mkdirSync(legacyCommandsGsd, { recursive: true }); + userArtifactStaging.restoreStagedUserArtifacts(legacyCommandsGsd, stagedLegacyArtifacts); + } + userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts); } } @@ -669,9 +877,9 @@ function _runLegacyInstallMigrations(runtime: string, configDir: string, scope: * @param runtime * @param configDir resolved runtime config directory * @param scope - * @returns saved legacy artifacts for post-removal migration, or null + * @returns staged legacy artifacts for post-removal migration, or null */ -function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): Map | null { +function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): ReturnType | null { // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs. // Prior to #1367 fix, Claude-local used commands/gsd/.md (colon-namespaced). // After #1367, Claude-local uses flat commands/gsd-.md. The inline uninstall @@ -683,7 +891,14 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s // is deferred and returned so the caller can apply it AFTER layout-driven // removal — this prevents the layout's gsd-* prefix removal from wiping the // freshly created skill dir (same pattern as _runLegacyInstallMigrations). - let savedLegacyArtifacts: Map | null = null; + // #2875 (#1874-F19): staged DURABLY to disk (userArtifactStaging), not just + // an in-memory Map — this function's own wipe below is raw `fs`, left + // unrouted by design (Phase 5 deliberately left the uninstall tree off the + // installFs() seam; 40-design.md "Explicitly out of scope"), but the + // staging call itself still routes through installFs() because the shared + // module does (ambient default: real fs here, since this call is never + // wrapped in withInstallFs). + let stagedLegacyArtifacts: ReturnType | null = null; // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global. // Claude local is intentionally excluded: the inline uninstall block (1c) handles // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it @@ -702,8 +917,16 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s if (isLegacyCommandsGsd) { const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsGsd)) { - savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); - fs.rmSync(legacyCommandsGsd, { recursive: true }); + // #2875 defect fix: DEGRADE, never abort uninstall, when the staging + // root cannot be resolved — skip this legacy-cleanup block (leave the + // stale dir in place) rather than wipe without a durable backup. + // Uninstall in particular must always be able to proceed past this + // point regardless of a hostile/broken `.gsd-staging` path. + const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir); + if (stagingRoot !== null) { + stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot); + fs.rmSync(legacyCommandsGsd, { recursive: true }); + } } } @@ -731,8 +954,8 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s } } - // Return saved artifacts so the caller can migrate after layout-driven removal. - return savedLegacyArtifacts; + // Return staged artifacts so the caller can migrate after layout-driven removal. + return stagedLegacyArtifacts; } // --------------------------------------------------------------------------- @@ -856,6 +1079,44 @@ function installRuntimeArtifacts( `installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`, ); } + // #2875 defect fix (--minimal regression closed): a restricted profile + // (e.g. --minimal) can legitimately stage ZERO agents — no skill in + // the profile's closure references a gsd-* role. The pre-#2875-Part-2 + // inline agent-staging loop this generic layout loop's agents handling + // replaced never created `agents/` at all under a minimal install (the + // now-deleted `isMinimalMode` branch skipped the whole step); this + // loop's own unconditional `mkdirSync` above regressed that — every + // profile, restricted or not, now gets an `agents/` dir materialized + // even when nothing will ever be written into it, breaking + // `.changeset/zesty-rams-march.md`'s "installed output is + // byte-identical to before for every runtime" claim. Restore the old + // behavior exactly for the `agents` kind specifically (skills/commands + // are unaffected — they are never legitimately empty): skip creating + // `dest` (and pruning/copying into it) entirely when this kind's + // already-staged `item.sourceDir` (built by createRuntimeArtifactInstallPlan + // BEFORE this loop) has nothing in it. + if (kind.kind === 'agents') { + const stagedAgentFiles = installFs().existsSync(item.sourceDir) + ? installFs().readdirSync(item.sourceDir).filter((f: string) => f.endsWith('.md')) + : []; + // #2875 defect fix, corrected: the ORIGINAL fix (see the comment + // above `installAgentsKindStandalone`) skipped this kind's stale- + // agent prune along with the write whenever a restricted profile + // (e.g. --minimal) staged zero agents — that also skipped + // `_removeGsdEntries`, so a full -> minimal downgrade left every + // previously-installed gsd-*.md/.toml agent file in place. The + // deleted pre-#2875 inline loop never did that: its stale-cleanup + // pre-pass ran UNCONDITIONALLY, and only the *write* of new agent + // files was gated on minimal mode. Restore that split here: prune + // first (no-ops via `_removeGsdEntries`'s own existsSync check when + // `dest` was never created, so a fresh install with nothing staged + // still never creates it below), then skip mkdir/copy when there is + // nothing to write. + _removeGsdEntries(dest, kind); + if (stagedAgentFiles.length === 0) { + continue; + } + } installFs().mkdirSync(dest, { recursive: true }); const preserved: string[] = []; if (kind.kind === 'skills' && installFs().existsSync(dest)) { @@ -1119,6 +1380,109 @@ function installOpencodeFamilySkills( return count; } +// --------------------------------------------------------------------------- +// installAgentsKindStandalone +// --------------------------------------------------------------------------- + +/** + * Install the descriptor-driven `agents` kind for a runtime OUTSIDE the + * generic `installRuntimeArtifacts` layout loop — i.e. any runtime/scope + * combination that never reaches that loop's own `layout.kinds` iteration. + * Two such call sites exist (#2875 Part 2): + * + * 1. **OpenCode-family runtimes** (OpenCode/Kilo, Task A) — `hostBehaviors. + * combinedFamilyInstall` makes `installRuntimeArtifacts` early-return into + * `installOpencodeFamilyArtifacts` instead, which stages commands+skills + * via its OWN bespoke writers and never called `resolveRuntimeArtifactLayout` + * for agents at all before this function existed. Declaring a + * `capability.json` `agents` entry for them without this would be inert + * on the real install path while live on `/gsd:surface` (#1879-F15). + * 2. **Claude local** (`bin/install.js`'s `install()`, `_isSkillsRuntime === + * false` branch) — `hostBehaviors.localInstallStyle === 'legacy-flat'` + * routes claude-local's commands/skills through `copyWithPathReplacement` + * instead of the layout loop, so it never reached `installRuntimeArtifacts` + * either. Its agents were previously written ONLY by the now-deleted + * inline agent-staging loop (Task C) — deleting that loop without this + * call site regressed claude-local's agents/ to empty (caught by the + * install-tree golden fixture, `tests/fixtures/install-tree/claude-local.json`). + * + * Reuses the SAME descriptor path every runtime inside the generic loop uses + * (`layout.kinds` → `agentsKindEntry.stage(resolvedProfile, agentCtx)` → + * `_copyStaged`), rather than forking a second agent-staging pipeline. A + * runtime/scope whose resolved layout declares no `agents` kind at all + * (e.g. pi, whose `artifactLayout` is empty for both scopes) is a no-op + * (`null`) — mirrors `installOpencodeFamilySkills`'s own + * `if (!skillsKindEntry) return 0` contract. + * + * @param runtime - canonical runtime id + * @param targetDir - resolved runtime config directory + * @param scope - install scope ('global' | 'local') + * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile() + * @param pathPrefix - computed config-path prefix for body rewrites (ADR-1235 §1 agentCtx) + * @param resolveAttribution - injection: (runtime) => attribution string | undefined + * @param capabilityRegistry - #2362: optional composed capability registry, threaded + * straight through to resolveRuntimeArtifactLayout (unused by the agents kind today, + * but kept for signature parity with the skills/commands siblings on this call tree) + * @returns `{ sourceDir, destDir }` describing what was written, or `null` when the + * runtime's layout declares no `agents` kind. + */ +function installAgentsKindStandalone( + runtime: string, + targetDir: string, + scope: string, + resolvedProfile: any, + pathPrefix: string, + resolveAttribution: ResolveAttribution = () => undefined, + capabilityRegistry?: any, +): { sourceDir: string; destDir: string } | null { + const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as 'global' | 'local', capabilityRegistry); + const agentsKindEntry = layout.kinds.find((k: any) => k.kind === 'agents'); + if (!agentsKindEntry) return null; + + // ADR-1235 §1: same agentCtx shape createRuntimeArtifactInstallPlan builds + // for the generic layout-driven loop (runtime-artifact-install-plan.cts) — + // targetDir IS the install root the inline agent loop called `targetDir`. + const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined; + const agentCtx = { runtime, pathPrefix, attribution, targetDir }; + const stagedDir: string = agentsKindEntry.stage(resolvedProfile, agentCtx); + + const stagedAgentFiles: string[] = installFs().existsSync(stagedDir) + ? installFs().readdirSync(stagedDir).filter((f: string) => f.endsWith('.md')) + : []; + + const installRoot: string = (typeof agentsKindEntry.home === 'string' && agentsKindEntry.home !== '') ? agentsKindEntry.home : targetDir; + const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, agentsKindEntry.destSubpath); + // Symlink-escape guard — same gate _copyStaged/installOpencodeFamilySkills apply + // to their own writes (#2393 GSD_ALLOW_SYMLINKED_DEST opt-in preserved). Runs + // even when nothing will be written this call — the stale-agent prune below + // (`_removeGsdEntries`) still touches `dest` whenever it already exists. + if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) { + throw new Error( + `installAgentsKindStandalone: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`, + ); + } + + // #2875 defect fix, corrected: the ORIGINAL fix returned `null` (no-op) + // whenever a restricted profile (e.g. --minimal) staged ZERO agents, + // which — because that early return sat ABOVE the prune call — also + // skipped `_removeGsdEntries`, leaving every previously-installed + // gsd-*.md/.toml agent file in place on a full -> minimal downgrade. The + // deleted pre-#2875 inline loop never did that: its stale-cleanup pre-pass + // ran UNCONDITIONALLY (removing gsd-*.md, plus .toml for codex), and only + // the *write* of new agent files was gated on minimal mode. Restore that + // split: prune first — a no-op via `_removeGsdEntries`'s own existsSync + // check when `dest` was never created, so a fresh install with nothing + // staged still never creates it below — then skip mkdir/copy (and return + // `null`, matching the doc comment above) when there is nothing to write. + _removeGsdEntries(dest, agentsKindEntry); + if (stagedAgentFiles.length === 0) return null; + + installFs().mkdirSync(dest, { recursive: true }); + _copyStaged(stagedDir, dest, agentsKindEntry, targetDir, runtime); + + return { sourceDir: stagedDir, destDir: dest }; +} + // --------------------------------------------------------------------------- // installOpencodeFamilyCommands // --------------------------------------------------------------------------- @@ -1413,6 +1777,11 @@ function installOpencodeFamilyArtifacts( ); installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution); const skillsWritten = installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry); + // #2875 Part 2 Task A: agents kind, reusing the SAME descriptor path the + // generic layout-driven loop uses (see installAgentsKindStandalone's own + // doc). A `null` result means this runtime's layout declares no `agents` + // kind — nothing written, nothing reported (no #1879-F15 inert claim). + const agentsResult = installAgentsKindStandalone(runtime, configDir, scope, resolvedProfile, pathPrefix, resolveAttribution, capabilityRegistry); _installNativePluginIfDeclared(runtime, configDir, behaviors, src); @@ -1427,6 +1796,7 @@ function installOpencodeFamilyArtifacts( kinds: [ { kind: 'commands', sourceDir: rawCommandsDir, destDir: commandDir }, { kind: 'skills', sourceDir: rawCommandsDir, destDir: configDir, written: skillsWritten }, + ...(agentsResult ? [{ kind: 'agents', sourceDir: agentsResult.sourceDir, destDir: agentsResult.destDir }] : []), ], cleanup: [], postSteps: { hermesBareStemCleanup: false, nativePlugin: Boolean(behaviors.nativePlugin) }, @@ -1455,9 +1825,9 @@ function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: st // Legacy cleanup before layout-driven removal (scope-aware to avoid // removing Claude local commands/gsd/ which is the primary install dir). - // Returns saved user artifacts so we can migrate AFTER layout removal + // Returns staged user artifacts so we can migrate AFTER layout removal // (the layout's gsd-* prefix pass would wipe a skill dir created here). - const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope); + const stagedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope); const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as any); const plan: any = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout); @@ -1490,8 +1860,38 @@ function uninstallRuntimeArtifacts(runtime: string, configDir: string, scope: st // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the // runtime-aware SKILL.md location after all layout-driven removal is // complete. Do NOT restore to commands/gsd/ — the user is uninstalling. - if (savedLegacyArtifacts) { + if (stagedLegacyArtifacts) { + // #2875: read the content back from the DISK-staged copy, matching + // _runLegacyInstallMigrations's call site — never restored on failure + // here either (the user is uninstalling; there is nothing to restore to). + // + // Security fix (parity with _runLegacyInstallMigrations's own guard, + // src/install-engine.cts / bin/install.js:8478): `readFileSync` ALWAYS + // follows a symlink. A staged `dev-preferences.md` that is itself a + // symlink (user-artifact-staging.cts's "Symlink safety" contract: a + // symlinked user artifact is recreated AS a symlink in the staging tree, + // never copied by content) would previously have its REFERENT's bytes + // read here and land in SKILL.md verbatim — e.g. a symlink to + // `~/.ssh/id_rsa` gets its private key content written into a file GSD + // loads into agent context. A symlink to a DIRECTORY instead throws + // EISDIR uncaught out of this function, which the caller never expected + // and which left the staged entry undiscarded (re-materializing on the + // next recovery pass and failing uninstall every time thereafter). + // lstatSync never follows a symlink; skip a symlinked name entirely + // (never migrated) rather than dereferencing it. + const savedLegacyArtifacts = new Map(); + for (const name of stagedLegacyArtifacts.names) { + const stagedPath = path.join(stagedLegacyArtifacts.filesDir, name); + // #2875 defect fix (crash resilience — TOCTOU, parity with + // _runLegacyInstallMigrations's own fix above): a raw `lstatSync` + // throws if `stagedPath` has vanished between staging and this read; + // degrade via `tryLstat` instead of crashing uninstall. + const stagedLstat = tryLstat(stagedPath); + if (!stagedLstat || stagedLstat.isSymbolicLink()) continue; + savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8')); + } migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts); } } @@ -1504,14 +1904,15 @@ export = { uninstallRuntimeArtifacts, installOpencodeFamilySkills, installOpencodeFamilyCommands, + installAgentsKindStandalone, installOpencodeFamilyArtifacts, _installNativePluginIfDeclared, _hostBehaviors, _copyStaged, hasExistingSymlinkBetween, isSymlinkedDestOptIn, - preserveUserArtifacts, - restoreUserArtifacts, + _resolveUserArtifactStagingRoot, + _tryResolveUserArtifactStagingRoot, migrateLegacyDevPreferencesToSkill, applyOpencodeFamilyPathPrefix, convertClaudeCommandToOpencodeSkill, diff --git a/src/install-fs-adapter.cts b/src/install-fs-adapter.cts index b537eb1ba..9b8338219 100644 --- a/src/install-fs-adapter.cts +++ b/src/install-fs-adapter.cts @@ -75,16 +75,34 @@ * a guard) must never leak a fake adapter into whatever runs next in the * same process (e.g. the next `node:test` in a shared worker). * - * ⚠️ PARTIAL-ADAPTER TRAP: `withInstallFs` merges the injected `partial` - * OVER the real adapter (`{ ...REAL_ADAPTER, ...partial }`) — any method the - * partial does not define resolves to REAL `node:fs`, silently. An - * incomplete fake is not a smaller fake adapter; for the methods it omits, - * it IS the real filesystem. A test asserting "no real IO happened" against - * a partial fake must either implement every method the exercised code path - * touches, or explicitly account for the ones it does not (see - * `tests/executed-plan.test.cjs`'s F2 test, which poisons real `fs` methods - * for exactly this reason — a gap here shows up as the poisoned method - * firing, not as a silent pass). + * ⚠️ PARTIAL-ADAPTER TRAP (#2875 defect fix — CLOSED, not merely documented): + * `withInstallFs` used to merge the injected `partial` OVER the real adapter + * (`{ ...REAL_ADAPTER, ...partial }`), so any method the partial did not + * define silently resolved to REAL `node:fs`. An incomplete fake was not a + * smaller fake adapter; for the methods it omitted, it WAS the real + * filesystem — a genuine production hazard, not only a test-authoring + * footgun: `user-artifact-staging.cts`'s `stageUserArtifacts` calls + * `installFs().rmSync(entryDir)` unconditionally as its entryDir-clearing + * step, and a `deps.fs` that omits `rmSync` (an easy oversight — nothing in + * the type system catches a plain-JS test object missing a key at runtime) + * silently deleted the real `/.gsd-staging/` on disk instead + * of failing loudly or staying confined to the fake's own store. + * + * `withInstallFs` now builds a GUARDED adapter instead: every method the + * partial does not define — except `realpathSync`, the one method this + * module's own contract documents as intentionally degrading to real fs + * (see its own doc comment below) — throws immediately IF CALLED, naming the + * missing method, instead of silently delegating to real fs. This only + * changes behavior for the injected-partial path (`withInstallFs(partial, + * fn)` with a defined `partial`); the no-adapter-injected default (AC4) + * still resolves to `REAL_ADAPTER` untouched, and any partial fake that + * genuinely implements every method its code path touches — the documented, + * intended usage — is byte-identical to before. A test asserting "no real IO + * happened" against a partial fake can now rely on this guard directly + * instead of having to hand-poison real `fs` methods itself (see + * `tests/executed-plan.test.cjs`'s F2 test, which still poisons real `fs` as + * defense-in-depth belt-and-suspenders, not because this guard is + * insufficient on its own). * * ───────────────────────────────────────────────────────────────────────── * SECURITY NOTE (40-design.md rows 6/7, H1-H5) @@ -149,6 +167,13 @@ interface InstallFsAdapter { realpathSync(p: string): string; unlinkSync(p: string): void; rmdirSync(p: string): void; + /** #2875 (epic #2866 Phase 6 / user-artifact-staging.cts): added so + * installer-migrations.cts's `copyPreservingSymlink` — reused by the + * staging module to copy a user-owned artifact without ever dereferencing + * a symlink (test-matrix A4) — can route ALL FIVE of its fs calls through + * this seam instead of punching a hole through it for just these two. */ + symlinkSync(target: string, p: string): void; + readlinkSync(p: string): string; /** Raw-fd streaming trio, added so `installer-migrations.cts`'s * `sha256File` can hash a file in fixed-size chunks through this seam * instead of buffering the whole file via `readFileSync` — see the @@ -177,6 +202,8 @@ const REAL_ADAPTER: InstallFsAdapter = { realpathSync: (p) => nodeFs.realpathSync(p), unlinkSync: (p) => nodeFs.unlinkSync(p), rmdirSync: (p) => nodeFs.rmdirSync(p), + symlinkSync: (target, p) => nodeFs.symlinkSync(target, p), + readlinkSync: (p) => nodeFs.readlinkSync(p), openSync: (p, flags) => nodeFs.openSync(p, flags), readSync: (fd, buffer, offset, length, position) => nodeFs.readSync(fd, buffer, offset, length, position), closeSync: (fd) => nodeFs.closeSync(fd), @@ -194,20 +221,59 @@ function installFs(): InstallFsAdapter { return current; } +// Methods allowed to silently degrade to real fs when a partial injected +// adapter omits them — see the module doc's "PARTIAL-ADAPTER TRAP". This is +// deliberately a single, narrow, documented exception: `realpathSync`'s own +// interface doc comment already promises graceful real-fs fallback (the +// symlink guard treats a `realpathSync` failure as "fall back to the lexical +// form" anyway, so an absent method degrading the same way changes nothing +// observable). Every other method is REQUIRED by `InstallFsAdapter`'s own +// type (no `?`) and now enforces that at runtime too. +const OPTIONAL_ADAPTER_METHODS: ReadonlySet = new Set(['realpathSync']); + /** - * Run `fn` with `partial` merged over the real adapter as the active - * install-fs adapter, restoring the previous adapter afterward — even on - * throw (see the module doc's re-entrancy/synchronous-only assumption for + * Build a merged adapter for an injected `partial`: every method `partial` + * defines is used as-is; `realpathSync` (only) falls back to real fs when + * absent; every other omitted method becomes a stub that THROWS if actually + * called, naming the missing method — turning a silent real-fs fall-through + * into an immediate, diagnosable failure (module doc "PARTIAL-ADAPTER TRAP"). + * A method never invoked by the exercised code path never throws, so this is + * safe for any existing partial fake that only implements what it touches. + */ +function buildGuardedAdapter(partial: Partial): InstallFsAdapter { + const guarded = {} as Record; + for (const key of Object.keys(REAL_ADAPTER) as (keyof InstallFsAdapter)[]) { + if (Object.prototype.hasOwnProperty.call(partial, key)) { + guarded[key] = partial[key]; + } else if (OPTIONAL_ADAPTER_METHODS.has(key)) { + guarded[key] = REAL_ADAPTER[key]; + } else { + guarded[key] = (..._args: unknown[]) => { + throw new Error( + `installFs().${key}(...) was called but the injected partial adapter does not implement it. ` + + `A fake adapter must implement every method its code path touches (install-fs-adapter.cts's ` + + `PARTIAL-ADAPTER TRAP) — falling through to real fs is no longer allowed for this method.`, + ); + }; + } + } + return guarded as unknown as InstallFsAdapter; +} + +/** + * Run `fn` with a GUARDED merge of `partial` over the real adapter as the + * active install-fs adapter, restoring the previous adapter afterward — even + * on throw (see the module doc's re-entrancy/synchronous-only assumption for * why a bare module-level variable is safe here, and why it would not be * under async interleaving or concurrent installs). `partial` undefined is * a no-op: `fn` runs against whatever adapter was already active (real fs * by default) — this is what keeps every existing `deps`-less call site - * (AC4) byte-identical. + * (AC4) byte-identical. See `buildGuardedAdapter` for what "guarded" means. */ function withInstallFs(partial: Partial | undefined, fn: () => T): T { if (!partial) return fn(); const previous = current; - current = { ...REAL_ADAPTER, ...partial }; + current = buildGuardedAdapter(partial); try { return fn(); } finally { diff --git a/src/install-model-override-resolver.cts b/src/install-model-override-resolver.cts new file mode 100644 index 000000000..23fa14086 --- /dev/null +++ b/src/install-model-override-resolver.cts @@ -0,0 +1,231 @@ +/** + * install-model-override-resolver — install-time per-agent model-override + * resolution (#2256 / #2794), extracted from the package-root `bin/install.js` + * (#2875 Part 2 / J8). + * + * bin/install.js's inline agent-staging loop duplicated this EXACT precedence + * chain across two runtime branches (OpenCode ~24 lines, Kilo ~24 lines) — + * `model_overrides[agent]` > `model_profile_overrides..` > omit. + * Extracted here — a `src/*.cts` module compiled into the shipped + * `gsd-core/bin/lib/` tree, mirroring `install-effort-resolver.cts`'s existing + * precedent (#2071) — so the descriptor-driven agents pipeline + * (`convertedAgentsKind` / `stageAgentsForRuntimeWithConverter`) and + * bin/install.js's own callers resolve through the SAME code. A single source + * of truth makes the two paths diverge only if this module changes, not + * silently across two hand-maintained copies (the Generative Fix Divergence + * class CLAUDE.md's "Known Defects" section warns about). + * + * `readGsdGlobalModelOverrides` / `readGsdEffectiveModelOverrides` / + * `readGsdRuntimeProfileResolver` are impure (config-file reads); + * `resolveAgentModelOverride` is pure given their pre-resolved outputs. + * + * #2875 defect fix: this module is on the `installRuntimeArtifacts` call tree + * (reached from `runtime-artifact-layout.cts`'s agents-kind `stage()` for the + * opencode/kilo converters) — every fs touch below routes through + * `installFs()` (install-fs-adapter.cts), matching `retired-artifact-cleanup.cts` + * / `user-artifact-staging.cts`'s existing precedent, instead of calling + * `node:fs` directly. A raw `fs` call here silently bypassed a fake adapter + * injected via `withInstallFs`/`installRuntimeArtifacts(..., { fs })`, + * exactly the class of bug those two modules were fixed for. + */ +import path from 'node:path'; +import os from 'node:os'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module +import installFsAdapter = require('./install-fs-adapter.cjs'); +const { installFs } = installFsAdapter; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- install-effort-resolver.cjs is an export= CommonJS module +import installEffortResolver = require('./install-effort-resolver.cjs'); +const { _readGsdConfigFile, _findAncestorGsdConfigPath } = installEffortResolver as { + _readGsdConfigFile: (absPath: string, label: string) => Record | null; + _findAncestorGsdConfigPath: (targetDir: string) => string | null; +}; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module +import modelCatalog = require('./model-catalog.cjs'); +const { MODEL_PROFILES: GSD_MODEL_PROFILES } = modelCatalog as unknown as { MODEL_PROFILES: Record> }; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module +import modelResolverModule = require('./model-resolver.cjs'); +const { resolveTierEntry: gsdResolveTierEntry } = modelResolverModule as { + resolveTierEntry: (opts: { runtime: string; tier: string; overrides: unknown }) => { model?: string } | null; +}; + +interface ReadOptions { + homedir?: () => string; +} + +interface RuntimeProfileResolver { + runtime: string; + resolve: (agentName: string) => { model?: string; reasoning_effort?: string } | null; +} + +/** + * Read `model_overrides` from `~/.gsd/defaults.json` at install time. + * Returns an object mapping agent names to model IDs, or null if the file + * doesn't exist or has no `model_overrides` entry. + * + * Mirrors bin/install.js's prior local `readGsdGlobalModelOverrides` exactly + * (silent try/catch — no stderr warning on malformed JSON, unlike + * `_readGsdConfigFile`'s effort-config sibling — preserved for byte-parity). + */ +function readGsdGlobalModelOverrides(options: ReadOptions = {}): Record | null { + try { + const home = options.homedir ? options.homedir() : os.homedir(); + const defaultsPath = path.join(home, '.gsd', 'defaults.json'); + if (!installFs().existsSync(defaultsPath)) return null; + const raw = installFs().readFileSync(defaultsPath, 'utf-8'); + const parsed = JSON.parse(raw) as { model_overrides?: unknown }; + const overrides = parsed.model_overrides; + if (!overrides || typeof overrides !== 'object') return null; + return overrides as Record; + } catch { + return null; + } +} + +/** + * Effective per-agent `model_overrides` for the install path. + * + * Merges `~/.gsd/defaults.json` (global) with per-project + * `/.planning/config.json`. Per-project keys win on conflict; keys + * present in only one source are preserved from that source (#2256). + * + * `targetDir` is the consuming runtime's install root (walks up to find + * `.planning/`). When `targetDir` is null/undefined only the global file is + * consulted. + * + * Returns a plain `{ agentName: modelId }` object, or `null` when neither + * source defines `model_overrides`. + */ +function readGsdEffectiveModelOverrides(targetDir: string | null = null, options: ReadOptions = {}): Record | null { + const global = readGsdGlobalModelOverrides(options); + + let projectOverrides: Record | null = null; + if (targetDir) { + // #2875 defect fix (Generative Fix Divergence): the 8-deep upward walk to + // `.planning/config.json` is single-sourced in install-effort-resolver.cts + // (`_findAncestorGsdConfigPath`) — this module was itself extracted FROM + // that one to stop duplicating shared install-time config logic, so a + // second hand-rolled copy of the walk here defeated the point. + const candidate = _findAncestorGsdConfigPath(targetDir); + if (candidate) { + try { + const parsed = JSON.parse(installFs().readFileSync(candidate, 'utf-8')) as { model_overrides?: unknown }; + if (parsed && typeof parsed === 'object' && parsed.model_overrides && typeof parsed.model_overrides === 'object') { + projectOverrides = parsed.model_overrides as Record; + } + } catch { + // Malformed config.json — fall back to global; readGsdRuntimeProfileResolver + // surfaces a parse warning via _readGsdConfigFile already. + } + } + } + + if (!global && !projectOverrides) return null; + // Per-project wins on conflict; preserve non-conflicting global keys. + return { ...(global || {}), ...(projectOverrides || {}) }; +} + +interface RuntimeProfileMergedConfig { + runtime: string | null; + model_profile: string; + model_profile_overrides: unknown; +} + +/** + * Build a runtime-aware tier resolver for the install path (#2517). + * + * Probes BOTH per-project `/.planning/config.json` AND + * `~/.gsd/defaults.json`, with per-project keys winning over global. + * + * Returns null if no `runtime` is configured, if `model_profile` is + * `inherit`, or if no project config is reachable AND `~/.gsd/defaults.json` + * declares no `model_profile` (#3543 — an unverifiable profile is never + * baked, letting the runtime's own default/session model govern). + */ +function readGsdRuntimeProfileResolver(targetDir: string | null = null): RuntimeProfileResolver | null { + const homeDefaults = _readGsdConfigFile( + path.join(os.homedir(), '.gsd', 'defaults.json'), + '~/.gsd/defaults.json', + ); + + // #2875 defect fix (Generative Fix Divergence): same shared walk as + // readGsdEffectiveModelOverrides above — see that call site's comment. + let projectConfig: Record | null = null; + if (targetDir) { + const candidate = _findAncestorGsdConfigPath(targetDir); + if (candidate) projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); + } + + const merged: RuntimeProfileMergedConfig = { + runtime: + (projectConfig && (projectConfig.runtime as string | undefined)) || + (homeDefaults && (homeDefaults.runtime as string | undefined)) || + null, + model_profile: + (projectConfig && (projectConfig.model_profile as string | undefined)) || + (homeDefaults && (homeDefaults.model_profile as string | undefined)) || + 'balanced', + model_profile_overrides: + (projectConfig && projectConfig.model_profile_overrides) || + (homeDefaults && homeDefaults.model_profile_overrides) || + null, + }; + + if (!merged.runtime) return null; + + if (!projectConfig && !(homeDefaults && homeDefaults.model_profile)) { + return null; + } + + const profile = String(merged.model_profile).toLowerCase(); + if (profile === 'inherit') return null; + + const runtime = merged.runtime; + return { + runtime, + resolve(agentName: string) { + const agentModels = GSD_MODEL_PROFILES[agentName]; + if (!agentModels) return null; + const tier = agentModels[profile] || agentModels.balanced; + if (!tier) return null; + return gsdResolveTierEntry({ + runtime, + tier, + overrides: merged.model_profile_overrides, + }); + }, + }; +} + +/** + * Resolve the effective model override for a single agent, given a + * pre-resolved `modelOverrides` map and `runtimeResolver` (both from the + * functions above). Pure — no filesystem access. + * + * Precedence (J8 — identical for kilo and opencode, resolved through this ONE + * shared function so the two runtimes can never diverge): + * 1. modelOverrides[agentName] (#2256 — explicit per-agent override) + * 2. runtimeResolver.resolve(agentName)?.model + * (#2794 — tier-based model_profile_overrides..) + * 3. null (omit — J7: the frontmatter key must not appear, not `null`/`""`) + */ +function resolveAgentModelOverride( + agentName: string, + modelOverrides: Record | null | undefined, + runtimeResolver: RuntimeProfileResolver | null | undefined, +): string | null { + const explicit = modelOverrides ? modelOverrides[agentName] : undefined; + if (explicit) return explicit; + if (runtimeResolver) { + const entry = runtimeResolver.resolve(agentName); + if (entry && entry.model) return entry.model; + } + return null; +} + +export = { + readGsdGlobalModelOverrides, + readGsdEffectiveModelOverrides, + readGsdRuntimeProfileResolver, + resolveAgentModelOverride, +}; diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 311589a17..8c6b2761c 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -34,11 +34,18 @@ const { processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, + deriveAgentName: _deriveAgentName, + applyAgentFrontmatterExtensions: _applyAgentFrontmatterExtensions, } = conversionModule as { applyAgentPathRewrites: (content: string, runtime: string, pathPrefix: string) => string; processAttribution: (content: string, attribution: string | null | undefined) => string; normalizeAgentBodyForRuntime: (content: string, runtime: string, cmdNames: string[]) => string; readGsdCommandNames: () => string[]; + // #2875 Part 2: single-sourced agent-name derivation + the frontmatter- + // extensions step (effort/disallowedTools injection), driven by the + // runtime descriptor's hostBehaviors.agentFrontmatterExtensions. + deriveAgentName: (fileName: string) => string; + applyAgentFrontmatterExtensions: (content: string, opts: { runtime: string; agentName: string; targetDir?: string | null }) => string; }; // #2995 (epic #1671 Phase 6.4): agent bodies join the fragment model. Markers are @@ -832,13 +839,25 @@ function stageSkillsForRuntimeAsSkills( /** * Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1). * When present, stageAgentsForRuntimeWithConverter applies the full inline-loop - * sequence per agent: pathRewrites → attribution → converter → normalize. - * The field names mirror the inline loop's available identifiers. + * sequence per agent: pathRewrites → attribution → converter → frontmatter + * extensions → normalize. The field names mirror the inline loop's available + * identifiers. + * + * `targetDir` (#2875 Part 2 / row I1-I3 — the one real per-agent resolution + * gap the agents-bypass closure needed): the install root, threaded through + * so the frontmatter-extensions step and (via the converter's own closure in + * `convertedAgentsKind`) model-override resolution can read + * `.planning/config.json` / `~/.gsd/defaults.json` exactly as the inline loop + * did with its own `targetDir` variable. Optional — a caller with no + * `targetDir` in scope (e.g. the feat-1173 synthetic-descriptor seam tests) + * degrades to `null`, matching `readGsdEffectiveEffortConfig(null)`'s own + * global-only-config contract. */ interface AgentCtx { runtime: string; pathPrefix: string; attribution: string | null | undefined; + targetDir?: string | null; } /** @@ -862,16 +881,25 @@ interface AgentCtx { * 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity) * 2. processAttribution (Co-Authored-By policy) * 3. converter (runtime-specific frontmatter/body transform) - * 4. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group) + * 4. applyAgentFrontmatterExtensions (#2875 Part 2: effort/disallowedTools, + * gated by hostBehaviors.agentFrontmatterExtensions — no-op for a runtime + * that declares nothing, e.g. every non-Claude runtime today) + * 5. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group) * When `agentCtx` is absent, only the converter is applied (backward-compat for * the feat-1173 synthetic-descriptor tests and the copilot/antigravity paths * that handle cross-cutting inside their converters). * * @param srcAgentsDir source agents directory (e.g. agents/) * @param resolvedProfile profile filter from resolveProfile() - * @param converter (content: string, isGlobal?: boolean) → string per-file - * converter; scope-aware converters (copilot/antigravity) - * read isGlobal, single-arg converters ignore it (#1173) + * @param converter (content: string, isGlobal?: boolean, meta?: {agentName: string}) → string + * per-file converter; scope-aware converters (copilot/antigravity) + * read isGlobal, single-arg converters ignore both extra args (#1173). + * `meta.agentName` (#2875 Part 2) is passed ONLY when `agentCtx` is + * present, letting a converter close over per-agent config + * (e.g. kilo/opencode model-override resolution in + * runtime-artifact-layout.cts's convertedAgentsKind) without widening + * every OTHER converter's contract — converters that don't declare a + * 3rd parameter simply never read it. * @param isGlobal install scope passed through to the converter * @param agentCtx optional cross-cutting context (ADR-1235 §1); when absent, * only the converter is applied (backward compat) @@ -879,15 +907,35 @@ interface AgentCtx { function stageAgentsForRuntimeWithConverter( srcAgentsDir: string, resolvedProfile: ResolvedProfile, - converter: (content: string, isGlobal?: boolean) => string, + converter: (content: string, isGlobal?: boolean, meta?: { agentName: string }) => string, isGlobal = false, agentCtx?: AgentCtx, ): string { if (!installFs().existsSync(srcAgentsDir)) return srcAgentsDir; const stageDir = mkInstallTempDir('gsd-profile-runtime-agents-'); + let entries: fs.Dirent[]; + try { + // #2284/#2875: readdirSync-ing the shipped agents/ source is isolated in + // its own try/catch so an unreadable source directory (permissions, a + // corrupted install, or — as the #2284 test suite demonstrates — the + // fail-closed injection harness) produces the SAME "refusing to install" + // fail-closed wording every other agents-dir-unreadable path in this + // codebase uses (bin/install.js's `_resolveAvailableGsdRoles` / + // `_assertRoleResolvable`), instead of an unrelated raw fs error message + // escaping uncaught. Hermes's role-dispatch validation depends on the + // SAME shipped agents/ directory being readable; before this runtime also + // declared an `agents` kind, this function was never reached on a Hermes + // install, so an unreadable source here silently surfaced as a raw error + // rather than the deliberate fail-closed contract #2284 established. + entries = installFs().readdirSync(srcAgentsDir, { withFileTypes: true }); + } catch (err) { + try { installFs().rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw new Error( + `stageAgentsForRuntimeWithConverter: could not resolve the shipped agents/ directory "${srcAgentsDir}" to stage — refusing to install (fail-closed, #2284/#2875): ${(err as Error).message}`, + ); + } try { - const entries = installFs().readdirSync(srcAgentsDir, { withFileTypes: true }); // Resolve cmdNames once per staging call (not per file) for performance. const cmdNames = agentCtx ? _readGsdCommandNames() : []; for (const entry of entries) { @@ -908,14 +956,20 @@ function stageAgentsForRuntimeWithConverter( // half-composed agent. content = _composeWorkflow(content, { sourcePath: agentSourcePath }); if (agentCtx) { + // #2875 Part 2 / row I3: derived exactly as the inline loop does — + // single-sourced via deriveAgentName (runtime-artifact-conversion.cts). + const agentName = _deriveAgentName(entry.name); // ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly) // Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity) content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix); // Step 2: attribution content = _processAttribution(content, agentCtx.attribution); // Step 3: converter (runtime-specific frontmatter/body transform) - content = converter(content, isGlobal); - // Step 4: normalize colon→hyphen refs (no-op for trivial group) + content = converter(content, isGlobal, { agentName }); + // Step 4: frontmatter extensions (effort/disallowedTools; no-op unless + // the runtime declares hostBehaviors.agentFrontmatterExtensions) + content = _applyAgentFrontmatterExtensions(content, { runtime: agentCtx.runtime, agentName, targetDir: agentCtx.targetDir }); + // Step 5: normalize colon→hyphen refs (no-op for trivial group) content = _normalizeAgentBodyForRuntime(content, agentCtx.runtime, cmdNames); } else { // Backward-compat: only apply the converter (no cross-cutting) diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 03eff4b83..78a035936 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -75,25 +75,6 @@ function sha256Text(value: string): string { return crypto.createHash('sha256').update(value).digest('hex'); } -/** - * Copy a managed path for the rollback snapshot or the user-facing backup, - * WITHOUT dereferencing a symlink. - * - * `fs.copyFileSync` follows symlinks, so a managed path that has been replaced - * by a link (tampering, or an unexpected user layout) would have had the - * LINK TARGET's bytes copied into `gsd-migration-journal/…-backups/` — e.g. a - * `gsd.cjs` symlinked at `~/.ssh/id_rsa` would land that key's contents in the - * backup tree. Nothing GSD installs is ever a symlink, so the faithful snapshot - * of a symlinked managed path is the link itself: recreating it preserves - * rollback fidelity (restore re-creates the same link) while never reading the - * referent. Deletion was already safe — `fs.rmSync` unlinks the link, never the - * target. - * - * Windows note: `fs.symlinkSync` can throw EPERM for unprivileged users. That - * surfaces as an apply failure and triggers the normal rollback path, which is - * the correct outcome — refusing to proceed beats silently copying referent - * bytes. - */ /** * Evaluate and, if safe, perform a `remove-empty-dir` action against `fullPath`. * @@ -163,17 +144,44 @@ function evaluateRemoveEmptyDir(configDir: string, fullPath: string): string { } } +/** + * Copy a managed path for the rollback snapshot or the user-facing backup, + * WITHOUT dereferencing a symlink. + * + * `fs.copyFileSync` follows symlinks, so a managed path that has been replaced + * by a link (tampering, or an unexpected user layout) would have had the + * LINK TARGET's bytes copied into `gsd-migration-journal/…-backups/` — e.g. a + * `gsd.cjs` symlinked at `~/.ssh/id_rsa` would land that key's contents in the + * backup tree. Nothing GSD installs is ever a symlink, so the faithful snapshot + * of a symlinked managed path is the link itself: recreating it preserves + * rollback fidelity (restore re-creates the same link) while never reading the + * referent. Deletion was already safe — `fs.rmSync` unlinks the link, never the + * target. + * + * Windows note: `fs.symlinkSync` can throw EPERM for unprivileged users. That + * surfaces as an apply failure and triggers the normal rollback path, which is + * the correct outcome — refusing to proceed beats silently copying referent + * bytes. + * + * #2875 (epic #2866 Phase 6): all five fs calls routed through `installFs()` + * so this primitive can be reused on the routed install path (by + * user-artifact-staging.cts) without punching a hole through the seam Phase 5 + * built. Every EXISTING caller of this function is on the migration + * plan/apply/rollback tree, which never wraps a call in `withInstallFs` — the + * ambient adapter there resolves to real `node:fs` by default, so this + * routing is behavior-preserving for them (test-matrix D2). + */ function copyPreservingSymlink(srcPath: string, destPath: string): void { - if (fs.lstatSync(srcPath).isSymbolicLink()) { + if (installFs().lstatSync(srcPath).isSymbolicLink()) { // symlinkSync fails with EEXIST on an occupied path, so clear it first. // Scoped to this branch on purpose: the regular-file path below keeps // copyFileSync's overwrite-in-place, so a mid-restore failure cannot leave // the destination destroyed. - fs.rmSync(destPath, { force: true }); - fs.symlinkSync(fs.readlinkSync(srcPath), destPath); + installFs().rmSync(destPath, { force: true }); + installFs().symlinkSync(installFs().readlinkSync(srcPath), destPath); return; } - fs.copyFileSync(srcPath, destPath); + installFs().copyFileSync(srcPath, destPath); } // Shared by readInstallManifest (on the installRuntimeArtifacts call tree — @@ -1219,6 +1227,7 @@ export = { acquireInstallMigrationLock, applyInstallerMigrationPlan, classifyArtifact, + copyPreservingSymlink, discoverInstallerMigrations, evaluateRemoveEmptyDir, MANIFEST_SCHEMA_VERSION, diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 353ba7bba..3161b65b1 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -41,6 +41,12 @@ import { scanFencedBlocks } from './markdown-sectionizer.cjs'; // runtime-homes.cjs + node builtins, never this module) — no cycle. See the // isGlobal sites below for why the boolean projection is centralized here too. import { isGlobalScope } from './install-scope.cjs'; +// #2875 Part 2: install-effort-resolver.cjs is a leaf-tier sibling (#2071) — +// used by applyAgentFrontmatterExtensions below to read the SAME merged +// effort config the install-time Claude .md injection has always read, +// without this module reaching upward into bin/install.js (ADR-1508). +import installEffortResolver = require('./install-effort-resolver.cjs'); +const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort, _getGsdEffortCatalog } = installEffortResolver; // #1383: resolve GSD's version WITHOUT a top-level // `require('../../../package.json')`. That require ran at module load on every @@ -2584,6 +2590,46 @@ function convertClaudeAgentToClineAgent(content) { return `${cleanFrontmatter}\n${body}`; } +/** + * Apply a runtime's descriptor-declared `hostBehaviors.brandingRewrites` to an + * agent body — the three literal-substring replaces the inline agent loop + * (bin/install.js) previously hardcoded per-branding-runtime (qwen/hermes): + * CLAUDE.md -> brandingRewrites['CLAUDE.md'] + * Claude Code -> brandingRewrites['Claude Code'] (word-boundary, \bClaude Code\b) + * .claude/ -> brandingRewrites['.claude/'] + * + * Data-driven (#2875 Part 2 / J10): reads the rewrite table from the + * runtime's OWN descriptor rather than hardcoding any runtime's strings, so a + * runtime declaring a different `brandingRewrites` table gets its own + * rewrites applied automatically. A runtime with no `brandingRewrites` + * declared returns `content` unchanged (no rewrite table to apply). + * + * Byte-identical to the inline loop's `else if (_hostBehaviors(runtime).brandingRewrites)` + * branch, including plain (non-word-boundary) `.replace(/\bClaude Code\b/g, ...)` + * semantics — J9. + */ +function applyAgentBrandingRewrites(content, runtime) { + const _b = _hostBehaviors(runtime).brandingRewrites; + if (!_b) return content; + let converted = content; + if (_b['CLAUDE.md']) converted = converted.replace(/CLAUDE\.md/g, _b['CLAUDE.md']); + if (_b['Claude Code']) converted = converted.replace(/\bClaude Code\b/g, _b['Claude Code']); + if (_b['.claude/']) converted = converted.replace(/\.claude\//g, _b['.claude/']); + return converted; +} + +/** + * Named branding converter for Hermes agents (#2875 Part 2 / J9-J10). + * `convertedAgentsKind` dispatches converters by exported name, so a named + * export is required even though the transform itself is fully generic + * (`applyAgentBrandingRewrites`) — resolved from + * `capabilities/hermes/capability.json`'s `hostBehaviors.brandingRewrites`, + * never hardcoded here. + */ +function convertClaudeAgentToHermesAgent(content) { + return applyAgentBrandingRewrites(content, 'hermes'); +} + /** * Convert Claude Code agent markdown to Codex agent format. * Applies base markdown conversions, then adds a header @@ -3334,6 +3380,136 @@ function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: st // ── End rewrite engine ──────────────────────────────────────────────────────── +/** + * Derive an agent's stem name from its source `.md` filename. Byte-identical + * to the inline agent loop's `entry.name.replace(/\.md$/, '')` (bin/install.js) + * — single-sourced here so the descriptor pipeline's per-agent resolution + * context (`agentCtx.agentName`, ADR-1235 §1 / #2875 Part 2 row I3) can never + * diverge from it. A filename with no trailing `.md` is returned unchanged + * (the regex has nothing to match) — I3's boundary row. + */ +function deriveAgentName(fileName: string): string { + return fileName.replace(/\.md$/, ''); +} + +/** + * #443 — Inject `effort: ` into YAML frontmatter of a Claude .md agent + * file in a newline-agnostic way (LF and CRLF source files are both handled). + * Relocated verbatim from bin/install.js (#2875 Part 2) — see + * `applyAgentFrontmatterExtensions` below for the orchestration that calls it. + * + * The function: + * - Detects the file's EOL (CRLF if the first `---` line ends with \r\n, + * otherwise LF). + * - Skips injection if an `effort:` key already exists in the frontmatter + * (idempotent). + * - Inserts `effort: ` immediately before the closing `---` delimiter, + * using the same EOL as the surrounding frontmatter so the output file + * stays EOL-consistent. + * - Returns the original content unchanged when no YAML frontmatter is found. + */ +function injectEffortFrontmatter(content: string, effortValue: string): string { + const eol = /^---\r\n/.test(content) ? '\r\n' : '\n'; + const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m; + const match = fmRe.exec(content); + if (!match) return content; // no YAML frontmatter — leave unchanged + + const fmBody = match[1]; // content between the two `---` lines + if (/^effort:/m.test(fmBody)) return content; + + const openLen = 3 + eol.length; // "---" + eol + const closingStart = match.index + openLen + fmBody.length; + + const before = content.slice(0, closingStart); + const after = content.slice(closingStart); + return `${before}effort: ${effortValue}${eol}${after}`; +} + +/** + * #767 — Inject `disallowedTools: ` into the YAML frontmatter of a + * Claude .md agent. Mirrors injectEffortFrontmatter: idempotent (skips if + * disallowedTools: already present), inserts immediately before the closing + * `---`. Claude-only — never call for other runtimes, which break on unknown + * frontmatter keys. Relocated verbatim from bin/install.js (#2875 Part 2). + */ +function injectDisallowedToolsFrontmatter(content: string, disallowedValue: string): string { + const eol = /^---\r\n/.test(content) ? '\r\n' : '\n'; + const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m; + const match = fmRe.exec(content); + if (!match) return content; // no YAML frontmatter — leave unchanged + + const fmBody = match[1]; // content between the two `---` lines + if (/^disallowedTools:/m.test(fmBody)) return content; + + const openLen = 3 + eol.length; // "---" + eol + const closingStart = match.index + openLen + fmBody.length; + + const before = content.slice(0, closingStart); + const after = content.slice(closingStart); + return `${before}disallowedTools: ${disallowedValue}${eol}${after}`; +} + +// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list. +// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one +// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally +// excluded (it legitimately uses Write AND Edit to create/patch test files). Relocated +// verbatim from bin/install.js (#2875 Part 2) — single source of truth for both the +// inline loop (which now requires this export) and the descriptor pipeline. +const READONLY_AGENT_DISALLOWED_TOOLS: Record = { + 'gsd-plan-checker': 'Write, Edit, MultiEdit', + 'gsd-integration-checker': 'Write, Edit, MultiEdit', + 'gsd-ui-checker': 'Write, Edit, MultiEdit', + 'gsd-verifier': 'Edit, MultiEdit', + 'gsd-doc-verifier': 'Edit, MultiEdit', + 'gsd-eval-auditor': 'Edit, MultiEdit', + 'gsd-ui-auditor': 'Edit, MultiEdit', +}; + +/** + * Post-converter frontmatter-extensions step (#2875 Part 2 / ADR-1235 §1 + * follow-up). Driven by the runtime descriptor's + * `hostBehaviors.agentFrontmatterExtensions` allow-list — Claude is its only + * declared consumer today (`agentFrontmatterExtensions: ["effort"]`). + * A runtime that does NOT declare the extension gets nothing injected (J3): + * OpenCode/Qwen/Hermes reject unknown frontmatter keys. + * + * Byte-identical to the inline agent loop's + * `if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort'))` + * block (bin/install.js): both the effort injection AND the disallowedTools + * injection are gated behind the SAME `'effort'` extension flag — there is no + * separate `'disallowedTools'` extension key, mirroring the loop exactly. + * + * J2 (the trap row): when the resolved effort is `'inherit'`, NO `effort:` + * key is written at all — the absence of the key IS the behavior (#3533). + * Writing `effort: inherit` would be a regression that looks like success. + * + * @param content agent .md content, already converter-transformed + * @param runtime canonical runtime ID + * @param agentName agent stem (from deriveAgentName), e.g. 'gsd-planner' + * @param targetDir install root — resolves .planning/config.json + ~/.gsd/defaults.json + */ +function applyAgentFrontmatterExtensions( + content: string, + { runtime, agentName, targetDir }: { runtime: string; agentName: string; targetDir?: string | null }, +): string { + const extensions = (_hostBehaviors(runtime).agentFrontmatterExtensions as string[] | undefined) || []; + if (!extensions.includes('effort')) return content; + + let result = content; + const effortCfg = readGsdEffectiveEffortConfig(targetDir ?? null); + const universalEffort = resolveInstallTimeEffort(effortCfg, agentName); + // #3533 (10d): 'inherit' means the effort: key must NOT exist — Claude Code + // then follows the session effort. The canonical source agents carry no + // effort key, so skipping injection is the whole job. + if (universalEffort !== 'inherit') { + const renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, universalEffort).value; + result = injectEffortFrontmatter(result, renderedEffort); + } + const disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[agentName]; + if (disallowedTools) result = injectDisallowedToolsFrontmatter(result, disallowedTools); + return result; +} + /** * Apply Co-Authored-By attribution policy to file content. * - null -> remove the Co-Authored-By line and its preceding blank line @@ -3439,6 +3615,10 @@ export = { convertClaudeAgentToCodebuddyAgent, convertClaudeAgentToClineAgent, convertClaudeAgentToCodexAgent, + // #2875 Part 2 (J10): Hermes named branding converter, generic underlying + // transform exported alongside it for direct reuse/testing. + convertClaudeAgentToHermesAgent, + applyAgentBrandingRewrites, // ADR-1239 / #2092 Phase B Upgrade 1: native .qwen/agents/*.md subagent // projection — registered by name so convertedAgentsKind's // conversionExports[converterName] dispatch (runtime-artifact-layout.cts) @@ -3459,6 +3639,15 @@ export = { // ADR-1235 §1: descriptor-driven agent cross-cutting applyAgentPathRewrites, normalizeAgentBodyForRuntime, + // #2875 Part 2: descriptor-driven agent frontmatter-extensions step + its + // single-sourced building blocks (also required back by bin/install.js so + // the inline loop and the descriptor pipeline resolve through the SAME + // code — no drift between the two byte-parity-gated pipelines). + deriveAgentName, + injectEffortFrontmatter, + injectDisallowedToolsFrontmatter, + READONLY_AGENT_DISALLOWED_TOOLS, + applyAgentFrontmatterExtensions, _computePathPrefix: computePathPrefix, _restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde, _applyRuntimeRewrites, diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index 311798552..786224bbe 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -31,6 +31,11 @@ interface AgentCtx { runtime: string; pathPrefix: string; attribution: string | null | undefined; + /** #2875 Part 2 (row I1): install root, threaded through so the + * descriptor pipeline's frontmatter-extensions step and model-override + * resolution can read config exactly as the inline agent loop's own + * `targetDir` variable did. */ + targetDir?: string | null; } interface ArtifactKind { @@ -196,7 +201,9 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan const isWindowsHost = (platform ?? process.platform) === 'win32'; const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined; - const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution }; + // #2875 Part 2 (row I1): layout.configDir IS the install root the inline + // agent loop called `targetDir` — same value, same resolution. + const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution, targetDir: layout.configDir }; for (const kind of layout.kinds) { let stagedDir: string; diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 01eb815ad..c033463cf 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -37,6 +37,11 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); const conversionExports = runtimeArtifactConversion as Record & { readGsdCommandNames?: () => string[]; }; +// #2875 Part 2 (J8): shared model-override precedence resolver — see its +// module doc for why kilo/opencode MUST resolve through this ONE function +// rather than re-deriving the chain per runtime. +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installModelOverrideResolver = require('./install-model-override-resolver.cjs'); import { posixNormalize } from './shell-command-projection.cjs'; // #2870: `isGlobalScope` centralizes the `scope === 'global'` boolean // projection both kind-builder closures below need at the converters' @@ -91,6 +96,11 @@ interface AgentCtx { runtime: string; pathPrefix: string; attribution: string | null | undefined; + /** #2875 Part 2 (row I1-I3): install root, threaded through to the + * frontmatter-extensions step and (for kilo/opencode's converters) the + * per-agent model-override resolution below. Mirrors install-profiles.cts's + * identically-named AgentCtx field — see its doc comment. */ + targetDir?: string | null; } interface ArtifactKind { @@ -255,14 +265,62 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art // inline agent loop, and installCodexConfig's per-agent .toml writer. The // exhaustive per-runtime sweep in tests/agent-fragments-emission.install.test.cjs // is what keeps a fourth from appearing uncomposed. - stage: (resolved) => stageAgentsForRuntimeWithConverter( + // #2875 Part 2 (row I2): agentCtx threaded through so a runtime using this + // converter:null builder (claude, plus any future identity-copy runtime) + // ALSO gets path-rewrites/attribution/frontmatter-extensions/normalize + // when a caller supplies agentCtx (createRuntimeArtifactInstallPlan / + // applySurface's agentCtx build). Previously this closure's `(resolved) =>` + // signature silently dropped the second arg every caller already passed — + // a caller with NO agentCtx in scope is unaffected (row I2: converter-only, + // as today), matching stageAgentsForRuntimeWithConverter's own contract. + stage: (resolved, agentCtx) => stageAgentsForRuntimeWithConverter( findAgentsSourceRoot(configDir), resolved, (content: string) => content, + false, + agentCtx, ), }; } +/** + * Runtime allowlist check for a descriptor-declared `converter` name, applied + * at DISPATCH time (security fix). `VALID_CONVERTER_NAMES` (capability- + * validator.cjs) is otherwise enforced ONLY at lint/build time + * (`check:contract-drift`) — every `conversionExports[converterName]` + * dynamic-property read below trusted that a `capability.json` reaching this + * far had already passed that check. It had not, in general: a hand-edited + * or malformed descriptor naming an Object-prototype member (`"constructor"`, + * `"toString"`, `"hasOwnProperty"`, ...) resolves to that member instead of + * throwing, producing garbage staged content rather than a loud failure — + * pre-existing, but promoted from the `/gsd-surface`-only path to the real + * install path for seven runtimes by #2875 Part 2's agents-bypass closure. + * Required lazily (call-time, not module-top) to avoid a load-time circular + * require, the same pattern install-engine.cts's `_hostBehaviors` already + * uses for `capability-registry.cjs`. Fails CLOSED: any error loading the + * allowlist itself (missing module, exotic bundling) is treated as "nothing + * is allowed", never as "skip the check". + */ +function _resolveNamedConverter(converterName: string, kindLabel: string): (...args: unknown[]) => unknown { + let validNames: Set | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + validNames = (require('./capability-validator.cjs') as { VALID_CONVERTER_NAMES: Set }).VALID_CONVERTER_NAMES; + } catch { + validNames = undefined; + } + if (!validNames || !validNames.has(converterName)) { + throw new Error( + `Unknown converter "${converterName}" declared for a ${kindLabel} kind — refusing to dispatch (not in capability-validator.cjs's VALID_CONVERTER_NAMES allowlist).`, + ); + } + const fn = conversionExports[converterName]; + if (typeof fn !== 'function') { + throw new Error(`Converter "${converterName}" is allowlisted but is not an exported function of runtime-artifact-conversion.cjs.`); + } + return fn as (...args: unknown[]) => unknown; +} + /** * Build a converted-agents kind descriptor for runtimes whose agent `.md` files * need runtime-specific frontmatter/body conversion (e.g. Copilot, Cursor, Codex). @@ -274,27 +332,80 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art * Agent filenames are preserved verbatim (the prefix is already embedded in the * agent stem — e.g. `gsd-planner.md`). * - * #1173 SCOPE — plumbing only (real install still elsewhere): this provides - * the converter dispatch + `isGlobal` scope threading for the descriptor's - * `agents` kind. As of #2092, 8 non-Claude runtimes DO declare a converted - * `agents` kind in their `capability.json` — qwen (`convertClaudeAgentToQwenAgent`) - * plus the 7 that already declared one before it (antigravity, augment, - * codebuddy, copilot, cursor, trae, windsurf) — so the descriptor-level - * declaration is no longer deferred. What IS still deferred is wiring - * `resolveRuntimeArtifactLayout`'s `agents` kind into the REAL install: - * `bin/install.js`'s agent-staging loop does not consume this module's - * `convertedAgentsKind` resolution at all — it dispatches the very same - * converter functions directly via `_hostBehaviors(runtime)` checks - * (`frontmatterDialect`, `brandingRewrites`, `isCopilot`/`isAntigravity`/…), - * duplicating the mapping declared here. That duplication is deliberate until - * the second `layout.kinds` consumer — `applySurface` / `/gsd:surface` / - * `--materialize` (`src/surface.cts`) — mirrors the legacy agent pipeline - * (Copilot's `.agent.md` filename rename, the cross-cutting path-prefix - * rewrite + attribution, stale-file cleanup, config-reading steps); declaring - * `bin/install.js` itself against this resolver before then would risk - * regressing the surface path. Until that follow-up lands, `bin/install.js` - * remains authoritative for the real install, and this `convertedAgentsKind` - * is exercised only by `/gsd:surface` and synthetic-descriptor seam tests. + * #1173 SCOPE, updated by #2875 Part 2 (the agents-bypass closure) — measured + * against the tree, not the ADR-3574 framing that preceded it: + * + * Of the four blockers this comment used to name for wiring `bin/install.js`'s + * inline agent loop against this resolver, THREE were already stale by the + * time #2875 measured them and are not re-litigated here: Copilot's + * `.agent.md` rename (the loop's own `destName = entry.name` comment records + * the ternary dropped in #2099; the descriptor fold applies it via + * `hostBehaviors.agentFileExtension`), the cross-cutting path-prefix rewrite + + * attribution (`stageAgentsForRuntimeWithConverter` already applies + * `applyAgentPathRewrites` -> `processAttribution` when `agentCtx` is + * present), and stale-file cleanup (`_removeGsdEntries` prunes every + * `gsd-`-prefixed entry in a kind's destSubpath, broader than the loop's own + * extension-gated check). + * + * The fourth — config-reading steps — was the real gap, and #2875 Part 2 + * closed it: `stageAgentsForRuntimeWithConverter` now takes a per-file + * `agentName` (`agentCtx.agentName`, ADR-1235 §1) and a `targetDir` + * (`agentCtx.targetDir`), which together let it (a) run a post-converter + * frontmatter-extensions step (`applyAgentFrontmatterExtensions`, driven by + * `hostBehaviors.agentFrontmatterExtensions` — Claude's `effort` + + * `disallowedTools` injection) and (b) let THIS function resolve a per-agent + * model override (`installModelOverrideResolver.resolveAgentModelOverride`, + * `model_overrides[agent]` > `model_profile_overrides..` > omit) + * before invoking a converter that needs it (kilo/opencode). Both pieces — + * plus a data-driven Hermes branding converter + * (`convertClaudeAgentToHermesAgent`, reading `hostBehaviors.brandingRewrites` + * rather than a hardcoded string table) — are single-sourced: `bin/install.js` + * requires the SAME functions this module does, so its inline loop and the + * descriptor path can no longer independently drift (the CLAUDE.md + * "Generative Fix Divergence" class the prior duplication risked). + * + * `tests/agent-descriptor-parity.test.cjs` proves byte-identical output + * between the inline loop and a SYNTHETIC descriptor registry (the same + * override seam `resolveRuntimeArtifactLayoutFromRegistry` exposes) for all + * six runtimes the inline loop still served: claude, cline, codex, hermes, + * kilo, opencode. + * + * Both findings the prior revision of this comment named as STILL deferred + * are now CLOSED (#2875 Part 2 Task A/B/C), measured against the real + * `capability.json` entries and the real production entry points, not + * argued from this module alone: + * + * 1. **kilo/opencode reaching `layout.kinds`.** `installEngine. + * installAgentsKindStandalone` (install-engine.cts) is called from inside + * `installOpencodeFamilyArtifacts` and resolves the agents kind through + * THIS SAME `resolveRuntimeArtifactLayout`/`convertedAgentsKind` path — + * `installOpencodeFamilyArtifacts` no longer stages only `commands` + + * `skills`. `bin/install.js`'s legacy-flat local path (claude-local, + * `hostBehaviors.localInstallStyle === 'legacy-flat'`) reaches the SAME + * generic loop only via `installRuntimeArtifacts`'s conditional + * `_isSkillsRuntime` branch; a call to `installAgentsKindStandalone` was + * added at claude-local's own call site to cover that scope too — the + * install-tree golden fixture (`tests/fixtures/install-tree/claude-local.json`) + * is what caught the gap when it was first missed. + * 2. **`/gsd:surface` / `applySurface` activation.** Confirmed convergent, + * not merely non-broken: for all six runtimes (claude, cline, codex, + * hermes, kilo, opencode), staging via `applySurface` into a freshly + * wiped `agents/` directory produces byte-identical output (including + * filenames) to `installRuntimeArtifacts`'s own write — verified directly + * against the built registry, not inferred. + * + * The inline loop (`_DESCRIPTOR_AGENTS_RUNTIMES` and the `bin/install.js` + * agent-staging block it gated) is DELETED — every runtime the registry + * declares an `agents` kind for is descriptor-driven now, including a + * seventh runtime (`kimi-code`) this comment's own prior measurement missed + * (it fell through the inline loop's generic `else if` branch, same as + * claude, with no dedicated dialect arm — caught by the same golden fixture). + * + * Codex's `config.toml [agents.gsd-*]` strip (`bin/install.js`, under + * `isMinimalMode` + `hostBehaviors.tomlConfigInstall`) remains the one + * genuinely out-of-scope constraint: it mutates a host config file, not the + * agents directory, and no descriptor kind models host-config mutation. It + * stays exactly where it is. * * Mirrors the `convertedCommandsKind` pattern (#785). * @@ -315,16 +426,42 @@ function convertedAgentsKind( destSubpath, prefix, stage: (resolved, agentCtx) => { - // isGlobal is threaded so scope-aware agent converters (copilot, antigravity) - // choose global-home vs workspace-relative paths; converters that only take - // (content) ignore the extra positional arg. Mirrors skillsKind's scope - // threading (#1173). // #2870: `scope` is this function's own parameter (default `'global'`, // so it is never undefined here), sourced upstream from the Install // Scope Module's resolved id. `isGlobalScope` projects it to the // boolean `stageAgentsForRuntimeWithConverter`'s positional API // requires — see its doc comment in install-scope.cts. - const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string; + const rawConverter = _resolveNamedConverter(converterName, 'agents') as + (content: string, arg2?: boolean | { isAgent?: boolean; modelOverride?: string | null }) => string; + + // #2875 Part 2 (J5-J8): kilo/opencode agent converters take an options + // bag (`{isAgent, modelOverride}`), not the `isGlobal` boolean every + // other agent converter's 2nd positional arg means — mirrors the + // inline loop's per-runtime `frontmatterDialect === 'opencode' | 'kilo'` + // branches (bin/install.js), which resolve model_overrides[agent] > + // model_profile_overrides.. > omit BEFORE calling the + // converter. Resolved ONCE per stage() call (not per file — a pure + // function of configDir/targetDir) via the single shared precedence + // resolver so kilo and opencode can never diverge (J8). + const needsModelOverride = converterName === 'convertClaudeToOpencodeFrontmatter' || converterName === 'convertClaudeToKiloFrontmatter'; + let converter: (content: string, isGlobal?: boolean, meta?: { agentName: string }) => string; + if (needsModelOverride) { + const overrideTargetDir = agentCtx?.targetDir ?? configDir; + const modelOverrides = installModelOverrideResolver.readGsdEffectiveModelOverrides(overrideTargetDir); + const runtimeResolver = installModelOverrideResolver.readGsdRuntimeProfileResolver(overrideTargetDir); + converter = (content, _isGlobal, meta) => { + const modelOverride = meta + ? installModelOverrideResolver.resolveAgentModelOverride(meta.agentName, modelOverrides, runtimeResolver) + : null; + return rawConverter(content, { isAgent: true, modelOverride }); + }; + } else { + // isGlobal is threaded so scope-aware agent converters (copilot, antigravity) + // choose global-home vs workspace-relative paths; converters that only take + // (content) ignore the extra positional arg. Mirrors skillsKind's scope + // threading (#1173). + converter = (content) => rawConverter(content, isGlobalScope(scope)); + } // ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan // for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter // can apply the full pre-converter + post-converter sequence in the correct order. @@ -422,7 +559,7 @@ function skillsKind( prefix, converter: converterName, stage: (resolved) => { - const realConverter = conversionExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string; + const realConverter = _resolveNamedConverter(converterName, 'skills') as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string; // Compute cmdNames once per stage call for performance (#3583). // Extra trailing args are ignored by converters that don't need them. The // isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is @@ -481,7 +618,7 @@ function convertedCommandsKind( destSubpath, prefix, stage: (resolved) => { - const converter = conversionExports[converterName] as (content: string, commandName: string) => string; + const converter = _resolveNamedConverter(converterName, 'commands') as (content: string, commandName: string) => string; return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix); }, }; diff --git a/src/surface.cts b/src/surface.cts index 5f40dcfe1..b2bbb39b5 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -71,6 +71,10 @@ interface AgentCtx { runtime: string; pathPrefix: string; attribution: string | null | undefined; + /** #2875 Part 2 (row I1): install root, mirrors + * runtime-artifact-install-plan.cts's identically-named field — see its + * doc comment. */ + targetDir?: string | null; } interface ArtifactKind { @@ -389,7 +393,8 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map/.gsd-staging/user-artifacts/` — a sibling of every wipe + * target this phase's call sites wipe, so staging survives all of them, + * while still resolving inside `configDir`): + * + * /-/record.json — the commit point + * /-/files/ — copied, symlink-safe + * + * The sha256-of-destDir component (not a raw path) keeps the entry-dir name + * filesystem-safe; the sha256-of-runId component (`opts.runId`, default + * `String(process.pid)`) discriminates concurrent RUNS targeting the same + * destDir (module doc "Concurrency" below) while still making repeat calls + * from the SAME run (the same process, the default case) reuse/overwrite the + * same entry rather than accumulating orphans (test-matrix A6) — hashing + * `runId` rather than using it raw keeps the same filesystem-safety/bounded- + * length guarantee the destDir hash already provides, regardless of what a + * caller passes. + * + * `record.json` (`{ destDir, names, timestamp }`) is written AFTER every + * file copy lands, never before — a half-written staging directory (a crash + * during the copy loop) has no record, and `recoverOrphanedUserArtifacts` + * ignores it (test-matrix B4/A7). The record's PRESENCE is what "staged" + * means; this module never infers completeness from directory contents + * alone. + * + * Confinement: this module carries the SAME "no policy, reuse the existing + * decision" discipline install-fs-adapter.cts documents for + * `hasExistingSymlinkBetween` / `assertDestWithinConfigHome` — it never + * reimplements either. Every path this module writes, and every path + * `recoverOrphanedUserArtifacts` reads OUT of an on-disk record before + * writing to it (attacker-influenceable input the moment an install runs on + * a shared machine — test-matrix E2), is re-resolved through the SAME + * `assertDestWithinConfigHome` (runtime-artifact-install-plan.cts) every + * other write on this call tree already uses, never a bespoke check. + * `recoverOrphanedUserArtifacts` ADDITIONALLY re-applies `hasExistingSymlinkBetween` + * (install-engine.cts) — the SAME guard `_copyStaged`/ + * `migrateLegacyDevPreferencesToSkill` apply to their own writes — against + * the record's `destDir` once it has cleared `assertDestWithinConfigHome` + * (#2875 defect fix, test-matrix E2 strengthened): lexical confinement alone + * does not detect a symlinked ANCESTOR directory (e.g. `/linkdir + * -> `, a record naming `/linkdir/sub/USER-PROFILE.md`) + * — `path.resolve` string math has no concept of what a path component + * actually IS on disk. `hasExistingSymlinkBetween` is required lazily + * (inside the function body, not at module top) via `require('./install- + * engine.cjs')` specifically to avoid a load-time circular require: + * install-engine.cts imports this module statically at its own top, so a + * static top-level import here would capture install-engine's exports + * object BEFORE its own `export =` assignment runs, permanently binding to + * an empty object (the classic `module.exports = {...}` circular-require + * footgun) — a lazy, call-time `require` instead resolves against the fully + * populated module, exactly matching the existing lazy-require precedent + * `runtime-artifact-install-plan.cts` already uses for the same reason. This + * module does not accept a `configDir` parameter to `stageUserArtifacts` (it + * cannot confine `stagingRoot` itself against a configHome — callers remain + * responsible for that; the same call-site pattern `_copyStaged`/ + * `migrateLegacyDevPreferencesToSkill` already use for + * `hasExistingSymlinkBetween`, including the symlinked-staging-root refusal, + * test-matrix E4 — see install-engine.cts's and bin/install.js's call sites). + * `recoverOrphanedUserArtifacts`, however, DOES take `configDir` explicitly — + * deriving it from `stagingRoot`'s own path shape would rest the E2 + * confinement guarantee on a naming convention rather than an explicit + * caller-supplied value, which is fragile in exactly the direction E2 exists + * to guard against. + * + * Never-overwrite (C2) and destination-symlink refusal: both + * `recoverOrphanedUserArtifacts` and `restoreStagedUserArtifacts` probe the + * destination with `lstatSync` (never `existsSync`) before writing (#2875 + * defect fix). `existsSync` FOLLOWS symlinks and reports `false` for a + * DANGLING one — a symlink whose target does not exist — so an + * `existsSync`-based "is something already there" check is blind to exactly + * a dangling symlink planted at the destination; `copyFileSync`/ + * `symlinkSync` (via `copyPreservingSymlink`) then follow that link and + * create the attacker-chosen target outside `configDir`. `lstatSync` never + * follows a symlink and succeeds for a dangling one, so it correctly reports + * "something is here" (a symlink, whatever its target) rather than "nothing + * is here". Every path name staged, restored, or recovered is REQUIRED to be + * a flat name — no path separator of either platform's flavor (`/` or `\`), + * matching every real caller's actual usage (a single flat filename like + * `'dev-preferences.md'`) and the `E3/E5` traversal/NUL-byte rejection this + * module already performs; a name containing a separator is rejected the + * same way (test-matrix rewritten E-series). + * + * Symlink safety: staged files are copied via installer-migrations.cts's + * `copyPreservingSymlink` (routed through `installFs()` by this same phase), + * which never dereferences a symlink — a managed path replaced by a link to + * (e.g.) `~/.ssh/id_rsa` cannot have the referent's bytes copied into the + * staging tree, or back out of it on restore/recovery (test-matrix A4). This + * is the SOURCE side; consumers that read a staged copy's CONTENT back + * (rather than re-copying it byte-for-byte via `copyPreservingSymlink`) must + * separately check `lstatSync(...).isSymbolicLink()` before calling + * `readFileSync` on it, or `readFileSync` will happily follow the staged + * symlink and read the referent's bytes — see install-engine.cts's + * `_runLegacyInstallMigrations` call site for the guarded pattern. + * + * Failure posture: staging (`stageUserArtifacts`) throws on any real IO + * failure — an existing file that cannot be copied, or the staging directory + * itself cannot be created. This is deliberate (test-matrix D4, a + * correctness row, not an error-handling row): a caller MUST let this + * propagate and abort BEFORE wiping the source directory, or the wipe + * proceeds having staged nothing, which is worse than no staging at all. + * `restoreStagedUserArtifacts`/`discardStagedUserArtifacts`/ + * `recoverOrphanedUserArtifacts` degrade instead: a missing staged file, a + * missing `stagingRoot`, or a malformed record are all treated as "nothing + * to do", never a throw — the durability property this module exists for + * would be self-defeating if RECOVERY could itself crash an install. + * `recoverOrphanedUserArtifacts`'s "never throws" contract is enforced with + * a per-FILE try/catch around every copy (a failure recovering one name is + * reported and skipped, the rest of the batch still proceeds) wrapped in a + * per-ENTRY try/catch around the whole batch (a failure this module did not + * anticipate — e.g. `symlinkSync` throwing `EPERM` for an unprivileged + * Windows user, or a staged `files/` that is unexpectedly a directory + * — is reported and the loop moves to the NEXT staging entry rather than + * propagating out of the function entirely). A batch that cannot be + * recovered is never swept — it is left in place for a future run or manual + * inspection, matching this module's existing "malformed record left alone" + * precedent (C6). + * + * CONCURRENCY (#2875 defect fix, test-matrix row F1 — previously documented + * as an open limitation requiring a cross-process lock; that reasoning was + * revisited and found unnecessarily strong): two RUNS (processes) racing the + * same `destDir` no longer share an entry directory. The staging key is + * `sha256(destDir)` COMBINED with `sha256(runId)` (`opts.runId`, default + * `String(process.pid)`) — the OS guarantees PID uniqueness among + * SIMULTANEOUSLY RUNNING processes, so two concurrently-live installs always + * key to different entry directories, and `stageUserArtifacts`'s + * entryDir-clearing step / `recoverOrphanedUserArtifacts`'s end-of-batch + * `rmSync` can therefore never destroy a DIFFERENT live run's in-flight or + * just-committed batch — the destructive clear is safe by construction, no + * lock needed. A crashed run's orphaned entry is not lost either: it is + * swept the ordinary way, by a LATER run's `recoverOrphanedUserArtifacts` + * pass (module doc "Failure posture" / C1-C4) — recovery iterates every + * entry under `stagingRoot` regardless of which run's key produced it, and + * the C2 never-overwrite guard means a stale orphan from an earlier crashed + * run can never clobber a newer, already-restored file. + * + * OWNER-LIVENESS GUARD (#2875 defect fix, closes the F1 residual above — a + * SECOND run's recovery pass executing WHILE a first, still-live run is + * between `stageUserArtifacts`'s commit and its own + * `restoreStagedUserArtifacts`/`discardStagedUserArtifacts` call is not an + * exotic interleaving: `recoverOrphanedUserArtifacts` runs as the FIRST + * statement of both `install()` and `uninstall()`, so it is exactly what + * happens whenever a second install starts while a first is still inside its + * wipe): `record.json` now carries `runId` RAW (unhashed — `stagingKeyFor` + * still only ever sees the hash) alongside `timestamp`, and + * `recoverOrphanedUserArtifacts` treats an entry as belonging to a STILL-LIVE + * run — leaving it COMPLETELY untouched, neither recovered nor swept, never + * even attempting `assertDestWithinConfigHome` against it — when ALL of: + * (1) `runId` parses as a valid positive-integer pid (`parseOwnerPid` — + * `"0"` and negative values are excluded because POSIX treats those as a + * process-GROUP signal, never a single pid); (2) `process.kill(pid, 0)` + * does not report `ESRCH` (`isProcessAlive` — cross-platform pid-existence + * probe that sends no actual signal; any outcome OTHER than "provably dead" + * is treated as "alive", including `EPERM`); (3) the record's `timestamp` is + * within `OWNER_LIVENESS_GRACE_MS` (5 minutes — a generous multiple of this + * codebase's own 60s npm-subprocess timeout convention, chosen because the + * failure direction is asymmetric: skipping a genuine orphan for up to 5 + * minutes merely delays recovery, the bytes stay on disk, whereas sweeping a + * live run's entry destroys data outright) of `clock.now()` — the pid-reuse + * guard: an OS pid recycled onto an unrelated, currently-alive later process + * cannot mask a genuine orphan past this window regardless of what + * `process.kill` reports. `recoverOrphanedUserArtifacts` now accepts the + * SAME `{clock}` seam `stageUserArtifacts` already does (default the real + * `Date`) — never reads the wall clock directly. Every one of these checks + * degrades toward NOT protecting (i.e. toward the pre-this-fix, always- + * eligible-for-recovery behavior) rather than toward protecting forever: a + * missing/non-numeric `runId` (an old record, or a hand-edited one) is never + * treated as live, and an unparsable `timestamp` never grants indefinite + * protection — see `parseOwnerPid`/`ownerStillLive`'s own doc comments. + * + * This closes the F1 residual as previously documented: recovery no longer + * has any interleaving with a live peer run that can destroy that peer's + * data. What remains, and is inherent to any liveness probe rather than a + * gap in this specific check: a false "alive" reading (an unrelated process + * reusing the crashed run's exact pid, itself started within the same + * `OWNER_LIVENESS_GRACE_MS` window) delays that one orphan's recovery by up + * to 5 minutes — never data loss, only a bounded delay, and exactly the + * direction this guard is biased toward. + * + * Explicitly out of scope (40-design.md "Explicitly out of scope"): fsync + * durability (crash-safe against process death only, not power loss); + * routing the raw-`fs` uninstall wipe at call site 2 + * (`_runLegacyUninstallCleanup`) — only the staging call itself routes + * through `installFs()` there, the surrounding wipe stays raw `fs` by + * design (Phase 5 deliberately left the uninstall tree unrouted). + */ + +import path from 'node:path'; +import crypto from 'node:crypto'; + +// #2875: this module's own fs seam — see install-fs-adapter.cts's module doc +// for the ambient-adapter delivery mechanism this reuses unchanged. +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installFsAdapter = require('./install-fs-adapter.cjs'); +const { installFs } = installFsAdapter; +// assertDestWithinConfigHome: the confinement decision this module reuses +// rather than reimplements (see module doc). Accessed via module ref at call +// time, matching install-engine.cts's own documented pattern for the same +// function, for test-stub compatibility. +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); +// copyPreservingSymlink: the symlink-safe copy primitive this module reuses +// rather than reimplements (see module doc "Symlink safety"). +// eslint-disable-next-line @typescript-eslint/no-require-imports +import installerMigrations = require('./installer-migrations.cjs'); + +/** Deterministic clock seam (repo convention — see e.g. research-store.cts): + * production code defaults to the real `Date` class; tests inject a fake via + * `node:test`'s `mock.timers` or a `{ now(): number }`-shaped double. Never + * read the wall clock directly. */ +interface ClockLike { + now(): number; +} + +interface StageOptions { + clock?: ClockLike; + /** Discriminates concurrent RUNS staging the same `destDir` (module doc + * "Concurrency") — defaults to `String(process.pid)`, real-process + * identity, never faked by production code. Tests inject a distinct + * string here to simulate two concurrent runs without forking a real OS + * process, the same role `clock` plays for simulating time. Hashed, not + * used raw, when building the staging key — see `stagingKeyFor`. */ + runId?: string; +} + +/** Maps a staged file name to a DIFFERENT destination file name on restore — + * e.g. `dev-preferences.md` (staged) -> `gsd-dev-preferences.md` (restored), + * the legacy-to-flat-layout migration bin/install.js's Claude commands-install + * path performs. A name absent from `rename` restores under its own staged + * name (the common case — every other call site). */ +interface RestoreOptions { + rename?: Record; +} + +interface StagedUserArtifacts { + /** Resolved absolute destDir this batch was staged from. */ + destDir: string; + /** The stagingRoot the caller supplied — carried so restore/discard never + * need it threaded back in separately. */ + stagingRoot: string; + /** Absolute path to this batch's staging entry dir (`//`). */ + entryDir: string; + /** Absolute path to this batch's `files/` subdir. */ + filesDir: string; + /** Absolute path to this batch's `record.json`. */ + recordPath: string; + /** File names actually staged — a subset of the `fileNames` passed to + * `stageUserArtifacts` (files absent from `destDir` at stage time are + * never included — test-matrix A2). */ + names: string[]; +} + +interface StagingRecord { + destDir: string; + names: string[]; + timestamp: string; + /** The staging `runId` (module doc "Concurrency"), carried RAW (not + * hashed) so `recoverOrphanedUserArtifacts` can tell a still-live owner + * from a genuine orphan (module doc "Owner-liveness guard"). Optional — + * a record written before this field existed, or hand-edited, omits or + * malforms it; that case is NEVER treated as "live forever" (see + * `parseOwnerPid`). */ + runId?: string; +} + +interface RecoveredEntry { + destDir: string; + name: string; +} + +interface SkippedEntry { + entryDir: string; + reason: + | 'destDir-outside-confinement' + | 'destDir-symlink-escape' + | 'files-symlink-escape' + | 'dest-already-present' + | 'recover-error' + | 'entry-recovery-error' + | 'owner-still-live'; + /** File name this row is about, when the failure is per-file rather than + * per-entry (`recover-error`). Absent for entry-level rows. */ + name?: string; +} + +interface RecoveryResult { + /** `{ destDir, name }` pairs actually restored to disk. */ + recovered: RecoveredEntry[]; + /** Staged entries found but not (fully) restored, with why. Never a throw + * — see module doc "Failure posture". */ + skipped: SkippedEntry[]; +} + +interface RecoveryOptions { + clock?: ClockLike; +} + +function stagingKeyFor(destDir: string, runId: string): string { + const destHash = crypto.createHash('sha256').update(path.resolve(destDir)).digest('hex').slice(0, 16); + // #2875 defect fix (test-matrix F1): hashed, not used raw — bounds the + // discriminator's contribution to the entry-dir name and keeps it + // filesystem-safe regardless of what a caller passes as `runId`, matching + // the same treatment `destDir` already gets. + const runHash = crypto.createHash('sha256').update(runId).digest('hex').slice(0, 8); + return `${destHash}-${runHash}`; +} + +// #2875 defect fix (test-matrix F1 residual — owner-liveness guard): the +// grace window past which a record is treated as orphaned REGARDLESS of +// whether `process.kill(pid, 0)` still reports the pid as alive — closes the +// pid-reuse gap (a crashed run's pid reassigned to an unrelated later +// process must not mask a genuine orphan forever). 5 minutes is a generous +// multiple of the codebase's own bound on a single install-tree operation +// (`npm` subprocess timeout convention is 60s — see this module's own +// "KNOWN DEFECTS" precedent in CLAUDE.md's "Unbounded Subprocesses" row); +// staging's own copy loop is a handful of small, flat, user-owned files, far +// cheaper than an `npm` call. The FAILURE DIRECTION is asymmetric by design +// (module doc "Owner-liveness guard"): skipping a real orphan for up to this +// long merely delays recovery — the bytes stay on disk — whereas sweeping a +// live run's entry destroys data outright, so this constant is deliberately +// generous rather than tight. +const OWNER_LIVENESS_GRACE_MS = 5 * 60 * 1000; + +/** + * Parses `runId` (raw, from an on-disk `record.json` — attacker/hand-edit + * influenceable, same threat model as every other on-disk field this module + * reads, module doc "Confinement") into a pid `process.kill` can safely take. + * Returns `null` — never throws — for anything that is not a plain positive + * integer string: absent (pre-this-fix record), the wrong type (a + * hand-edited record could put a number, an object, anything), `"0"` + * (`process.kill(0, ...)` signals the WHOLE process group, never a single + * pid — deliberately excluded by the `[1-9]` leading-digit requirement), or + * a negative/non-integer value (POSIX also treats a negative pid as a + * process-GROUP signal). `null` here means "no liveness claim to evaluate" + * — the caller falls back to unconditional recovery eligibility, the exact + * pre-this-fix behavior, so an old or malformed record is never treated as + * "live forever" (module doc "Owner-liveness guard"). + */ +function parseOwnerPid(runId: unknown): number | null { + if (typeof runId !== 'string' || !/^[1-9][0-9]*$/.test(runId)) return null; + const pid = Number(runId); + return Number.isSafeInteger(pid) ? pid : null; +} + +/** + * True when a process with this pid currently exists. `process.kill(pid, 0)` + * sends no signal, only probes existence — throws `ESRCH` ("no such + * process") when it is provably dead. Any OTHER outcome (`EPERM` — the + * process exists but this process lacks permission to signal it; any other + * platform quirk) is treated as "still alive" — the same NOT-sweeping bias + * `parseOwnerPid`/`OWNER_LIVENESS_GRACE_MS` apply: an ambiguous liveness + * result must never be read as license to sweep. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +/** + * True when `record` still belongs to a run recovery must leave entirely + * alone (module doc "Owner-liveness guard") — a valid pid (`parseOwnerPid`), + * that pid currently exists (`isProcessAlive`), AND the record is within + * `OWNER_LIVENESS_GRACE_MS` of `clock.now()`. All three degrade toward + * "not protected" (eligible for ordinary recovery, this function's existing + * pre-this-fix behavior) rather than toward "protected forever": a missing + * pid, a dead pid, or an unparsable/missing `timestamp` (already required + * and validated elsewhere in this module, but re-checked here defensively) + * all return `false`. The grace check is unconditional once a pid IS deemed + * alive — a stale-but-apparently-live claim past the grace window is treated + * as orphaned regardless (the pid-reuse guard). + */ +function ownerStillLive(record: StagingRecord, clock: ClockLike): boolean { + const pid = parseOwnerPid(record.runId); + if (pid === null) return false; + if (!isProcessAlive(pid)) return false; + const recordMs = Date.parse(record.timestamp); + if (!Number.isFinite(recordMs)) return false; + return clock.now() - recordMs < OWNER_LIVENESS_GRACE_MS; +} + +/** + * Lazily require install-engine.cjs's `hasExistingSymlinkBetween` / + * `isSymlinkedDestOptIn` — see module doc "Confinement" for why this MUST be + * a call-time `require`, not a static top-level import (a real circular + * require: install-engine.cts imports this module statically). + */ +interface InstallEngineSymlinkGuard { + hasExistingSymlinkBetween: (root: string, fullPath: string, options?: { allowOptInFollow?: boolean }) => boolean; + isSymlinkedDestOptIn: () => boolean; +} + +function _installEngineSymlinkGuard(): InstallEngineSymlinkGuard { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const mod: InstallEngineSymlinkGuard = require('./install-engine.cjs'); + return mod; +} + +/** Flat names only — no path separator of either platform's flavor. See + * module doc "Never-overwrite (C2) and destination-symlink refusal". */ +function isFlatName(name: string): boolean { + return !name.includes('/') && !name.includes('\\'); +} + +/** + * True when `p` has ANYTHING at all on disk — including a dangling symlink, + * which `existsSync` reports as absent (see module doc). Used everywhere + * this module must refuse to write over an existing destination rather than + * trusting `existsSync`. + */ +function lstatPresent(p: string): boolean { + try { + installFs().lstatSync(p); + return true; + } catch { + return false; + } +} + +/** + * Copy `srcPath` to `destPath` without ever dereferencing a symlink — thin + * wrapper over installer-migrations.cts's `copyPreservingSymlink`, routed + * through the SAME `installFs()` adapter active for this call (ambient; see + * install-fs-adapter.cts's module doc). + */ +function stagedCopy(srcPath: string, destPath: string): void { + installerMigrations.copyPreservingSymlink(srcPath, destPath); +} + +/** + * Stage `fileNames` found in `destDir` to durable on-disk storage under + * `stagingRoot`, BEFORE the caller wipes `destDir`. + * + * Absent files are skipped, never an error (A2). Throws on any real IO + * failure — see module doc "Failure posture" (D4): callers MUST let this + * propagate and abort before wiping `destDir`. + * + * `stagingRoot` itself is NOT re-confined here — callers own that (module + * doc "Confinement"). + * + * The staging entry dir is cleared FIRST (removed, then recreated) so a + * repeat call for the same `destDir` FROM THE SAME RUN (same `runId`, the + * default `process.pid` case — A6) never leaves files from a PRIOR batch + * lingering alongside the new one — recovery and restore both iterate + * `record.names` so stale leftovers were already inert, but a stale-free + * staging tree is what an operator inspecting it on disk expects to see. A + * DIFFERENT run's entry for the same `destDir` keys differently (module doc + * "Concurrency") and is never touched by this clearing step. + */ +function stageUserArtifacts(destDir: string, fileNames: string[], stagingRoot: string, opts: StageOptions = {}): StagedUserArtifacts { + const { clock = Date, runId = String(process.pid) } = opts; + const key = stagingKeyFor(destDir, runId); + const entryDir = path.join(stagingRoot, key); + const filesDir = path.join(entryDir, 'files'); + const recordPath = path.join(entryDir, 'record.json'); + + installFs().rmSync(entryDir, { recursive: true, force: true }); + installFs().mkdirSync(filesDir, { recursive: true }); + + const stagedNames: string[] = []; + for (const name of fileNames) { + // #2875 defect fix: flat names only (module doc "Confinement") — a + // caller passing a name with a path separator is a caller bug, hard- + // throw matching this function's own "Failure posture" (D4), never a + // silent skip. + if (!isFlatName(name)) { + throw new Error(`stageUserArtifacts: file name "${name}" must be a flat name — no path separator is allowed`); + } + const srcPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(destDir, name); + if (!installFs().existsSync(srcPath)) continue; // A2: absent, not staged, no throw + const destInStaging = runtimeArtifactInstallPlan.assertDestWithinConfigHome(filesDir, name); + stagedCopy(srcPath, destInStaging); + stagedNames.push(name); + } + + // Commit point (A1/A7): written AFTER every copy lands. A crash before + // this line leaves `filesDir` populated but recordless — + // recoverOrphanedUserArtifacts treats that as incomplete, never a recovery + // source (B4). + const record: StagingRecord = { + destDir: path.resolve(destDir), + names: stagedNames, + timestamp: new Date(clock.now()).toISOString(), + // #2875 defect fix (F1 residual — owner-liveness guard): carried RAW so + // a later recovery pass can tell a still-live owner from a genuine + // orphan (module doc "Owner-liveness guard") — see `ownerStillLive`. + runId, + }; + installFs().writeFileSync(recordPath, JSON.stringify(record), 'utf8'); + + return { destDir: record.destDir, stagingRoot, entryDir, filesDir, recordPath, names: stagedNames }; +} + +/** + * Copy every staged file back into `destDir` (normally the SAME destDir the + * batch was staged from, after the caller recreated it post-wipe). + * + * Deliberately does NOT discard the staged copy — kept a separate step + * (`discardStagedUserArtifacts`) because not every call site restores + * unconditionally (e.g. a migration call site restores only on migration + * FAILURE, and wants the staged copy gone only once that decision is final). + * + * `opts.rename` restores a staged name under a DIFFERENT destination file + * name (e.g. a legacy `dev-preferences.md` restored as + * `gsd-dev-preferences.md`) — see `RestoreOptions`'s own doc comment. A name + * absent from the map restores under its own staged name, unchanged. + * + * A name skips (never restores) if it is not a flat name (module doc + * "Confinement"), or if `destPath` already has ANYTHING at it — including a + * dangling symlink, which `existsSync` cannot see (module doc + * "Never-overwrite (C2) and destination-symlink refusal", #2875 defect fix): + * this function has no "already present, that's fine" semantics to fall + * back on the way `recoverOrphanedUserArtifacts` does, so the safe, + * degrade-not-throw choice is to refuse writing through whatever is already + * there rather than silently following it. + */ +function restoreStagedUserArtifacts(destDir: string, staged: StagedUserArtifacts, opts: RestoreOptions = {}): void { + const { rename = {} } = opts; + for (const name of staged.names) { + if (!isFlatName(name)) continue; + const srcInStaging = runtimeArtifactInstallPlan.assertDestWithinConfigHome(staged.filesDir, name); + if (!installFs().existsSync(srcInStaging)) continue; + const destName = Object.prototype.hasOwnProperty.call(rename, name) ? rename[name] : name; + if (!isFlatName(destName)) continue; + const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(destDir, destName); + // #2875 defect fix: refuse to write through a pre-existing symlink at + // destPath (dangling or not) — copyFileSync/symlinkSync would otherwise + // follow it and land content outside destDir. + if (lstatPresent(destPath)) continue; + installFs().mkdirSync(path.dirname(destPath), { recursive: true }); + stagedCopy(srcInStaging, destPath); + } +} + +/** + * Remove a staging batch entirely. A staged copy is not a backup — see + * module doc "Explicitly out of scope" / 40-design.md "Not-corruption". + * Idempotent: a missing `entryDir` is a silent no-op (`force: true`), + * matching every sibling best-effort cleanup in this codebase's install + * tree. + */ +function discardStagedUserArtifacts(staged: StagedUserArtifacts): void { + installFs().rmSync(staged.entryDir, { recursive: true, force: true }); +} + +/** + * Find and restore every COMPLETE (record-committed) staging batch under + * `stagingRoot` — batches orphaned by a crash between `stageUserArtifacts` + * and the caller's own restore/discard. + * + * Never overwrites a present file (C2 — a file that IS there was not lost; + * "present" is decided by `lstatSync`, not `existsSync`, so a dangling + * symlink at the destination also counts as present — module doc + * "Never-overwrite (C2) and destination-symlink refusal", #2875 defect fix). + * Never throws, PERIOD: a missing `stagingRoot` (C5), a malformed/truncated + * record (C6), a record naming a `destDir` outside `configDir` either + * lexically or through a symlinked ancestor (E2), or ANY unanticipated + * failure recovering one file or one whole batch (a `symlinkSync` `EPERM` + * for an unprivileged Windows user, a staged `files/` that turns out + * to be a directory, an `mkdirSync`/`rmSync` failure) is reported via + * `skipped` and the function moves on — to the next name, then to the next + * staging entry — rather than propagating (#2875 defect fix: this contract + * was previously false; `mkdirSync`/`stagedCopy`/the final `rmSync` were all + * unguarded, so a single bad entry could throw out of this function + * entirely, and because the throw happened before that entry was ever + * cleaned up, it also permanently bricked every FUTURE install/uninstall — + * this function is called as the very first step of both). + * + * Re-confinement (E2): the record's `destDir` is data, not policy — this + * function never trusts it directly. `configDir` is a REQUIRED, EXPLICIT + * parameter (not derived from `stagingRoot`'s own path shape — resting E2's + * confinement guarantee on a path-naming convention would let a caller that + * passes a differently-shaped `stagingRoot` silently get the wrong + * confinement root, in either direction, which is exactly what E2 exists to + * prevent). The recorded `destDir` is re-resolved through the SAME + * `assertDestWithinConfigHome` every other write on this call tree uses + * against the CALLER-SUPPLIED `configDir`, THEN through the SAME + * `hasExistingSymlinkBetween` `_copyStaged`/`migrateLegacyDevPreferencesToSkill` + * apply to their own writes (#2875 defect fix — `assertDestWithinConfigHome` + * alone is pure lexical `path.resolve` string math and cannot see a + * symlinked ANCESTOR directory between `configDir` and the recorded + * `destDir`). A record naming a `destDir` outside it, lexically or via a + * symlinked ancestor, is refused (`skipped`), never written. + * + * Callers MUST invoke this before the ordinary preserve step, from a + * PRODUCTION entry point (test-matrix C7 — anti-inertness). Calling this + * function directly and never wiring it into a real install path is exactly + * the #1879-F15 inert-fix failure mode this module exists to avoid; see + * bin/install.js's `install()`/`uninstall()` for the wiring. + */ +function recoverOrphanedUserArtifacts(stagingRoot: string, configDir: string, opts: RecoveryOptions = {}): RecoveryResult { + const { clock = Date } = opts; + const result: RecoveryResult = { recovered: [], skipped: [] }; + if (!installFs().existsSync(stagingRoot)) return result; // C5: no-op, no throw, no dir created + + let entries: { name: string; isDirectory(): boolean }[]; + try { + entries = installFs().readdirSync(stagingRoot, { withFileTypes: true }); + } catch { + return result; + } + + const configHome = path.resolve(configDir); + const symlinkGuard = _installEngineSymlinkGuard(); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const entryDir = path.join(stagingRoot, entry.name); + // #2875 defect fix: the whole per-entry body is wrapped so nothing this + // module did not anticipate can propagate out of the function — see the + // doc comment above. A caught failure is reported once for the batch and + // the loop proceeds to the NEXT entry; it is deliberately NOT swept + // (matches the existing "malformed record left alone" precedent, C6) so + // it remains available for inspection or a future recovery attempt. + try { + const recordPath = path.join(entryDir, 'record.json'); + if (!installFs().existsSync(recordPath)) continue; // B4: half-staged, not a recovery source + + let record: StagingRecord; + try { + const parsed = JSON.parse(installFs().readFileSync(recordPath, 'utf8')) as Partial | null; + if ( + !parsed || typeof parsed !== 'object' || + typeof parsed.destDir !== 'string' || + !Array.isArray(parsed.names) || + !parsed.names.every((n) => typeof n === 'string') + ) { + continue; // C6: malformed shape, ignored — never a crash + } + record = parsed as StagingRecord; + } catch { + continue; // C6: corrupt/truncated JSON, ignored — never a crash + } + + // #2875 defect fix (F1 residual — owner-liveness guard): checked + // BEFORE any confinement resolution or write attempt — a still-live + // owner's entry is left completely untouched, not merely un-swept + // (module doc "Owner-liveness guard"): this run does not restore the + // file to destDir on the live owner's behalf either, which would race + // that owner's own still-in-progress wipe/restore cycle. + if (ownerStillLive(record, clock)) { + result.skipped.push({ entryDir, reason: 'owner-still-live' }); + continue; + } + + const filesDir = path.join(entryDir, 'files'); + // #2875 defect fix (security — source-side symlink escape): every write + // below reads FROM filesDir via a plain srcPath = filesDir/name join + // (E3/E5 guard it against traversal/NUL, never against the `files` + // PATH COMPONENT ITSELF being a symlink). record.json is data an + // entryDir owner controls (module doc "Confinement" — same + // attacker-influenceable-on-a-shared-machine threat E2 already treats + // destDir against); a real `entryDir` whose `files` child is a symlink + // to e.g. `/etc` or `/root/.ssh` would have its referent's bytes + // dereferenced by the per-file `existsSync`/`stagedCopy` reads below, + // landing victim-readable content at an attacker-named path inside + // configDir. Reuse the SAME `hasExistingSymlinkBetween` guard the + // dest side (E2) and every other write on this call tree already + // applies, walked from entryDir (a real, non-symlinked directory — + // `entry.isDirectory()` above already excludes a symlinked entryDir on + // POSIX) to filesDir, BEFORE any name in `record.names` is read. + // Per-file symlinks INSIDE files/ are untouched by this check and + // remain legitimate (module doc "Symlink safety" — a symlinked staged + // user artifact is expected and copied via copyPreservingSymlink, + // never dereferenced). + // + // `allowOptInFollow` is hardcoded `false` here, NOT + // `symlinkGuard.isSymlinkedDestOptIn()` — `GSD_ALLOW_SYMLINKED_DEST` is + // documented (install-engine.cts `isSymlinkedDestOptIn`) as relaxing + // only the destination-side "pre-existing symlink that points outside + // configHome" refusal (the user asserting they own/trust a symlinked + // WRITE destination, e.g. nix-darwin's `~/.claude` symlink). This is a + // SOURCE read path — `entryDir`/`filesDir` is GSD-owned internal + // staging state this module itself creates, never a user-authored + // layout, so there is no legitimate opt-in case here. Honoring the + // dest opt-in on this read would re-open exactly the escape this + // guard exists to close: a `files` component symlinked to e.g. + // `/root/.ssh` would be followed, and the per-file reads below would + // dereference and copy the referent's bytes into configDir. + if (symlinkGuard.hasExistingSymlinkBetween(entryDir, filesDir, { allowOptInFollow: false })) { + result.skipped.push({ entryDir, reason: 'files-symlink-escape' }); + continue; + } + // #2875 defect fix (security — cwd-dependent confinement): a relative + // `record.destDir` makes `path.relative(configHome, record.destDir)` + // resolve the SECOND (relative) argument against `process.cwd()` + // internally, not against `configHome` — the CLI's cwd at the moment + // recovery runs, which an attacker who can plant a record.json does + // not need to know or control to exploit (module doc "Confinement"). + // `stageUserArtifacts` (this module's own writer) always records an + // ALREADY-`path.resolve`d, absolute `destDir` — a relative value here + // only ever comes from a forged or hand-edited record, exactly the + // untrusted-data case this function's confinement re-resolution + // exists for. Refuse it the same way a lexically-escaping absolute + // destDir is refused below, rather than let `path.relative` silently + // reinterpret it against the wrong root. + if (!path.isAbsolute(record.destDir)) { + result.skipped.push({ entryDir, reason: 'destDir-outside-confinement' }); + continue; + } + let confinedDestDir: string; + try { + const relDest = path.relative(configHome, record.destDir); + confinedDestDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configHome, relDest); + } catch { + result.skipped.push({ entryDir, reason: 'destDir-outside-confinement' }); // E2 (lexical) + continue; + } + // E2 (ancestor symlink): assertDestWithinConfigHome above is pure + // lexical path math and does not see a symlinked ANCESTOR directory + // between configHome and confinedDestDir — reuse the SAME guard every + // other write on this call tree applies (module doc "Confinement"). + if (symlinkGuard.hasExistingSymlinkBetween(configHome, confinedDestDir, { allowOptInFollow: symlinkGuard.isSymlinkedDestOptIn() })) { + result.skipped.push({ entryDir, reason: 'destDir-symlink-escape' }); // E2 + continue; + } + + // #2875 defect fix: tracks whether ANY name in this batch hit a + // genuine, unanticipated recovery error (as opposed to a deliberate, + // accounted-for skip like "already present" or "rejected name") — see + // the cleanup decision below. + let anyGenuineFailure = false; + for (const name of record.names) { + // Per-FILE guard (#2875 defect fix): one bad name must not abort the + // rest of the batch. + try { + if (!isFlatName(name)) continue; // flat names only — module doc "Confinement" + let srcPath: string; + let destPath: string; + try { + srcPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(filesDir, name); + destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(confinedDestDir, name); + } catch { + continue; // E3/E5: traversal or NUL byte in a staged name — reject that name + } + if (!installFs().existsSync(srcPath)) continue; + // C2 (#2875 defect fix): lstatSync, not existsSync — existsSync + // FOLLOWS symlinks and reports false for a DANGLING one, so it is + // blind to exactly the case an attacker would plant at destPath to + // bypass "never overwrite" (module doc). + if (lstatPresent(destPath)) { + result.skipped.push({ entryDir, reason: 'dest-already-present', name }); // C2: never overwrite + continue; + } + installFs().mkdirSync(path.dirname(destPath), { recursive: true }); // C3: recreate a since-removed destDir + stagedCopy(srcPath, destPath); + result.recovered.push({ destDir: confinedDestDir, name }); + } catch { + anyGenuineFailure = true; + result.skipped.push({ entryDir, reason: 'recover-error', name }); // never throws — degrade per-file + } + } + + // #2875 defect fix: a batch with a genuine per-file failure is NEVER + // swept — discarding the staging entry here would silently destroy the + // only durable copy of a file this module could not otherwise recover, + // exactly the loss this module exists to prevent. It is left in place + // for a future recovery attempt or manual inspection, same as a + // malformed record (C6) or a half-staged entry (B4). Only a batch that + // is FULLY, DELIBERATELY accounted for (every name recovered, or + // deliberately skipped because the destination already had something) + // is removed — it is not a backup (module doc "Explicitly out of + // scope"). Best-effort: a failure performing the removal itself (rare + // — permissions, a Windows symlink-cleanup edge case) is swallowed + // rather than propagated; the entry is left for a future run. + if (anyGenuineFailure) continue; + try { + installFs().rmSync(entryDir, { recursive: true, force: true }); + } catch { + // Intentionally swallowed — see comment above. + } + } catch { + result.skipped.push({ entryDir, reason: 'entry-recovery-error' }); // never throws — degrade per-entry + } + } + + return result; +} + +export = { + stageUserArtifacts, + restoreStagedUserArtifacts, + discardStagedUserArtifacts, + recoverOrphanedUserArtifacts, + // #2875 defect fix: exported for a fast-check property test (CLAUDE.md + // "Property-Based Testing" — parsers must carry at least one) — additive, + // byte-identical for every existing caller of this module. + parseOwnerPid, +}; diff --git a/tests/agent-descriptor-parity.install.test.cjs b/tests/agent-descriptor-parity.install.test.cjs new file mode 100644 index 000000000..b07345745 --- /dev/null +++ b/tests/agent-descriptor-parity.install.test.cjs @@ -0,0 +1,646 @@ +'use strict'; + +// allow-test-rule: source-text-is-the-product #2875 — the J-row assertions +// pattern-match rendered agent-.md frontmatter (`effort:`, `model:`, +// disallowedTools, branding text). That frontmatter IS the deployed artifact +// each runtime loads at dispatch time (no typed IR exists between +// applyAgentFrontmatterExtensions/injectEffortFrontmatter and the file a +// runtime reads) — matching CONTRIBUTING.md's `source-text-is-the-product` +// exemption, not a workaroundable "hide the grep in a parser" case. + +/** + * agent-descriptor-parity.install.test.cjs — #2875 Part 2 (the agents-bypass + * closure), 50-test-matrix.md sections H, I, J (rows H1-H8, I1-I3, J1-J10). + * + * HONEST BASELINE (rewritten — a prior revision of this file was found to + * test the wrong thing on every axis; see the fixed defects below): + * + * bin/install.js's inline agent-staging loop is GONE (deleted in the same + * commit that made every runtime descriptor-driven for agents). There is no + * second, independently-maintained agent-staging pipeline left in this + * codebase to diff against — an "old pipeline vs new pipeline" comparison is + * therefore IMPOSSIBLE post-deletion, and a prior revision of this file's + * header claiming to compare against "bin/install.js's inline agent-staging + * loop" was false the moment that loop was deleted. + * + * What THIS revision actually proves instead, and how: + * + * 1. The REAL production entry point (`installAgentsKindStandalone`, + * `install-engine.cjs`) is driven directly, against the REAL, BUILT + * `capability-registry.cjs` — no synthetic registry override. Every H + * row therefore byte-compares actual output written by a real + * `capabilities//capability.json` edit, not a hand-rolled + * stand-in for one. A wrong-but-syntactically-valid `converter` name + * landing in a real descriptor changes the ACTUAL side's output and is + * caught (see H8's red-proof, which demonstrates this directly). + * + * 2. `computeExpectedOutput` (the "oracle") independently assembles the + * EXPECTED bytes by calling the individual conversion PRIMITIVES + * directly: `composeWorkflow`, `applyAgentPathRewrites`, + * `processAttribution`, the runtime's converter function (dispatched + * off `EXPECTED_CONVERTER_NAME_BY_RUNTIME` — a hand-verified, + * independent map, NOT read from the descriptor under test), + * `applyAgentFrontmatterExtensions`, `normalizeAgentBodyForRuntime`. + * These primitives are — by construction, not accident — + * single-sourced: there is no live duplicate of `composeWorkflow` or + * `applyAgentPathRewrites` to diff against either, because #2875 Part 2 + * collapsed the duplication into these shared functions. Reusing them + * here does not defeat the test: the property under test in every H row + * is "does capability.json's declared `converter` name resolve to the + * CORRECT converter and get invoked in the correct position of the + * pipeline" — which the independent `EXPECTED_CONVERTER_NAME_BY_RUNTIME` + * map exists specifically to keep decoupled from the descriptor. + * + * 3. Both scopes are exercised for every runtime that declares a + * per-scope `agents` kind (claude, cline, codex, hermes, kilo, opencode, + * kimi-code × global+local). The prior revision was global-only, which + * is exactly the class of gap that let two separate agents-drop + * regressions reach `next` undetected: cline-local (fixed alongside + * this rewrite — capabilities/cline/capability.json's `local` + * artifactLayout now declares an `agents` kind) and kimi-code-local + * (fixed the same way — its `local` artifactLayout previously declared + * no `agents` kind at all, so the deleted inline loop's implicit + * scope-gate was silently replaced with NO gate, dropping every + * kimi-code local install's agents/gsd-*.md entirely). + * + * H8 is mandatory, not optional: a parity harness never demonstrated failing + * is decoration. It feeds the oracle a DELIBERATELY WRONG (but real, + * allowlisted) converter name and asserts the comparison goes red against + * the REAL (correctly-configured) production output — proving that if + * capability.json's declared converter ever regressed, this exact harness + * would catch it. + * + * WHAT THIS FILE DOES NOT PROVE: H8's red-proof demonstrates exactly one + * failure class — the oracle and the real descriptor path resolving to a + * DIFFERENT converter for the same runtime. It says nothing about a bug + * INSIDE a shared primitive (`composeWorkflow`, `applyAgentPathRewrites`, + * `processAttribution`, `applyAgentFrontmatterExtensions`, + * `normalizeAgentBodyForRuntime`, or a named converter itself): both the + * oracle (point 2 above) and the real descriptor path call the identical + * function, so a regression inside one of those functions changes BOTH + * sides identically and every H row stays green. That is a deliberate, + * unavoidable consequence of point 2's single-sourcing (there is no second, + * independently-implemented copy of those primitives left to diff against + * post-#2875-Part-2) — this file is a converter-WIRING parity gate, not a + * substitute for direct unit coverage of the primitives themselves (which + * live in their own owning test files, e.g. `runtime-artifact-conversion`'s + * suite). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const LIB_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib'); + +const runtimeArtifactConversion = require(path.join(LIB_DIR, 'runtime-artifact-conversion.cjs')); +const installModelOverrideResolver = require(path.join(LIB_DIR, 'install-model-override-resolver.cjs')); +const installEngine = require(path.join(LIB_DIR, 'install-engine.cjs')); +const capabilityRegistry = require(path.join(LIB_DIR, 'capability-registry.cjs')); +const { composeWorkflow } = require(path.join(LIB_DIR, 'workflow-fragments.cjs')); +const installBin = require(path.join(REPO_ROOT, 'bin', 'install.js')); + +// --------------------------------------------------------------------------- +// Fixture builders +// --------------------------------------------------------------------------- + +/** Deterministic sample agent sources — NOT the real agents/ tree (the real + * tree's exact roster can change independently of this suite). Covers: + * ~/.claude/ + $HOME/.claude/ (anchored + bare) path forms, a Co-Authored-By + * trailer, and one row-J4 "disallowedTools hit" agent name plus one "miss". */ +const SAMPLE_AGENTS = { + 'gsd-planner.md': [ + '---', + 'name: gsd-planner', + 'description: Plans phases for GSD workflows.', + 'tools: Read, Write, Edit, Bash', + '---', + '', + 'Reads @~/.claude/gsd-core/commands/gsd/plan-phase.md and $HOME/.claude/CLAUDE.md.', + 'Bare forms too: ~/.claude and $HOME/.claude.', + 'References Claude Code and CLAUDE.md and .claude/settings.json.', + '', + 'Co-Authored-By: Claude ', + '', + ].join('\n'), + 'gsd-plan-checker.md': [ + '---', + 'name: gsd-plan-checker', + 'description: Checks plans for GSD workflows.', + 'tools: Read, Grep, Glob', + '---', + '', + 'A read-only checker agent (row J4 "hit" — declared in READONLY_AGENT_DISALLOWED_TOOLS).', + '', + ].join('\n'), +}; + +function buildSourceTree(agentFiles) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-src-')); + const commandsGsd = path.join(root, 'commands', 'gsd'); + fs.mkdirSync(commandsGsd, { recursive: true }); + const agentsDir = path.join(root, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + for (const [name, content] of Object.entries(agentFiles)) { + fs.writeFileSync(path.join(agentsDir, name), content); + } + return { root, commandsGsd, agentsDir }; +} + +/** A fresh "install destination" dir with a `.gsd-source` marker pointing at + * `commandsGsd` — the same marker findInstallSourceRoot/findAgentsSourceRoot + * read (runtime-artifact-layout.cts). */ +function buildTargetDir(commandsGsd) { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-dest-')); + fs.writeFileSync(path.join(targetDir, '.gsd-source'), commandsGsd); + return targetDir; +} + +// --------------------------------------------------------------------------- +// Oracle — independently-assembled EXPECTED output (see header doc) +// --------------------------------------------------------------------------- + +/** Hand-verified, independent of any capability.json — this is the thing + * every H row's ACTUAL side (a real capability.json) is checked against. + * `null` means converter:null (identity — claude, kimi-code). */ +const EXPECTED_CONVERTER_NAME_BY_RUNTIME = { + claude: null, + 'kimi-code': null, + cline: 'convertClaudeAgentToClineAgent', + codex: 'convertClaudeAgentToCodexAgent', + hermes: 'convertClaudeAgentToHermesAgent', + kilo: 'convertClaudeToKiloFrontmatter', + opencode: 'convertClaudeToOpencodeFrontmatter', +}; + +/** Converter functions callable by name — bin/install.js still owns + * cline/codex/kilo/opencode's (never migrated to runtime-artifact-conversion.cjs); + * hermes's is descriptor-native (#2875 Part 2 / J9-J10). */ +const NAMED_CONVERTERS = { + convertClaudeAgentToClineAgent: installBin.convertClaudeAgentToClineAgent, + convertClaudeAgentToCodexAgent: installBin.convertClaudeAgentToCodexAgent, + convertClaudeAgentToHermesAgent: runtimeArtifactConversion.convertClaudeAgentToHermesAgent, + convertClaudeToKiloFrontmatter: installBin.convertClaudeToKiloFrontmatter, + convertClaudeToOpencodeFrontmatter: installBin.convertClaudeToOpencodeFrontmatter, +}; + +/** kilo/opencode take an options bag (`{isAgent, modelOverride}`), resolved + * ONCE per call via the single shared precedence resolver (J5-J8) — every + * other converter here takes only `content`. */ +const MODEL_OVERRIDE_CONVERTER_NAMES = new Set(['convertClaudeToKiloFrontmatter', 'convertClaudeToOpencodeFrontmatter']); + +/** + * Assemble the EXPECTED per-file output for `runtime` from `agentsDir`, + * calling the shared conversion primitives directly (see header doc for why + * this is not circular). `converterNameOverride`, when passed, replaces + * `EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime]` — used ONLY by H8's + * red-proof to inject a deliberately wrong converter. + */ +function computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride) { + const converterName = converterNameOverride !== undefined ? converterNameOverride : EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime]; + const { pathPrefix, attribution, targetDir } = ctx; + const out = new Map(); + const entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const agentSourcePath = path.join(agentsDir, entry.name); + let content = fs.readFileSync(agentSourcePath, 'utf8'); + // Step 0 (#2995): strip gsd:section markers — same order the real + // pipeline uses (stageAgentsForRuntimeWithConverter, install-profiles.cts). + content = composeWorkflow(content, { sourcePath: agentSourcePath }); + const agentName = runtimeArtifactConversion.deriveAgentName(entry.name); + // Step 1: path rewrites + content = runtimeArtifactConversion.applyAgentPathRewrites(content, runtime, pathPrefix); + // Step 2: attribution + content = runtimeArtifactConversion.processAttribution(content, attribution); + // Step 3: converter — dispatched off the INDEPENDENT expected-name map, + // never off the descriptor under test. + if (converterName) { + const fn = NAMED_CONVERTERS[converterName]; + assert.ok(typeof fn === 'function', `oracle: no converter function registered for "${converterName}"`); + if (MODEL_OVERRIDE_CONVERTER_NAMES.has(converterName)) { + const modelOverride = installModelOverrideResolver.resolveAgentModelOverride( + agentName, + installModelOverrideResolver.readGsdEffectiveModelOverrides(targetDir), + installModelOverrideResolver.readGsdRuntimeProfileResolver(targetDir), + ); + content = fn(content, { isAgent: true, modelOverride }); + } else { + content = fn(content); + } + } + // converter:null (claude, kimi-code) — content unchanged by this step. + // Step 4: frontmatter extensions (effort/disallowedTools) + content = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime, agentName, targetDir }); + // Step 5: normalize colon->hyphen refs + content = runtimeArtifactConversion.normalizeAgentBodyForRuntime( + content, + runtime, + runtimeArtifactConversion.readGsdCommandNames(), + ); + out.set(entry.name, content); + } + return out; +} + +// --------------------------------------------------------------------------- +// Real descriptor path — the REAL production entry point, REAL registry +// --------------------------------------------------------------------------- + +/** + * Drive the ACTUAL production entry point (`installAgentsKindStandalone`) + * against the REAL, built `capability-registry.cjs` — no override. This is + * exactly what `bin/install.js`'s `install()` calls for every runtime whose + * layout is not otherwise reached by the generic `installRuntimeArtifacts` + * loop (and, for the runtimes reached by that loop, produces identical + * output to it — both route through the SAME `convertedAgentsKind` / + * `_resolveNamedConverter` dispatch in runtime-artifact-layout.cts). + */ +function runRealDescriptorPath(runtime, scope, targetDir, ctx) { + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const result = installEngine.installAgentsKindStandalone( + runtime, + targetDir, + scope, + resolvedProfile, + ctx.pathPrefix, + () => ctx.attribution, + ); + const out = new Map(); + if (!result) return out; + for (const entry of fs.readdirSync(result.destDir, { withFileTypes: true })) { + if (entry.isFile()) out.set(entry.name, fs.readFileSync(path.join(result.destDir, entry.name), 'utf8')); + } + return out; +} + +// --------------------------------------------------------------------------- +// Comparison helper +// --------------------------------------------------------------------------- + +/** Every (runtime, scope) pair the REAL capabilities//capability.json + * today declares an `agents` kind for (measured 2026-08-17). K1 below is the + * machine-checked guarantee that this list cannot silently go stale — it + * sweeps the real registry and fails if a declarant is missing here. */ +const RUNTIME_SCOPE_PAIRS = [ + ['claude', 'global'], ['claude', 'local'], + ['cline', 'global'], ['cline', 'local'], + ['codex', 'global'], ['codex', 'local'], + ['hermes', 'global'], ['hermes', 'local'], + ['kilo', 'global'], ['kilo', 'local'], + ['opencode', 'global'], ['opencode', 'local'], + ['kimi-code', 'global'], ['kimi-code', 'local'], +]; + +/** Model override literal shared by J8's two `_stageWithModelOverride` calls. */ +const J8_OVERRIDE_MODEL = 'shared/explicit-model'; + +/** + * Standalone helper (module scope, no test-context access — the + * CONTRIBUTING.md "Never use try/finally inside test bodies" exemption) for + * J8: stage a single-agent source tree with a real `.planning/config.json` + * model_overrides block for `runtime` through the real descriptor path. + */ +function _stageWithModelOverride(runtime, overrideModel) { + const agentFiles = { 'gsd-planner.md': SAMPLE_AGENTS['gsd-planner.md'] }; + const { commandsGsd, root } = buildSourceTree(agentFiles); + const targetDir = buildTargetDir(commandsGsd); + fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(targetDir, '.planning', 'config.json'), + JSON.stringify({ model_overrides: { 'gsd-planner': overrideModel } }), + ); + try { + const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir }; + return runRealDescriptorPath(runtime, 'global', targetDir, ctx); + } finally { + cleanup(root); + cleanup(targetDir); + } +} + +function comparePipelines(runtime, scope, converterNameOverride) { + const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS); + const targetDir = buildTargetDir(commandsGsd); + try { + const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir }; + const expected = computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride); + const actual = runRealDescriptorPath(runtime, scope, targetDir, ctx); + return { expected, actual }; + } finally { + cleanup(root); + cleanup(targetDir); + } +} + +/** Asserts H1-H6 + H7 in one shot: same filenames (Set equality, order-free) + * AND byte-identical content per filename. */ +function assertMapsIdentical(expected, actual) { + assert.deepEqual( + [...expected.keys()].sort(), + [...actual.keys()].sort(), + 'filenames diverged between the oracle and the real descriptor path (row H7)', + ); + for (const [name, expectedContent] of expected) { + assert.equal( + actual.get(name), + expectedContent, + `content diverged for ${name} between the oracle and the real descriptor path`, + ); + } +} + +// --------------------------------------------------------------------------- +// H rows — the parity gate, one row per (runtime, scope) +// --------------------------------------------------------------------------- + +for (const [runtime, scope] of RUNTIME_SCOPE_PAIRS) { + test(`agent-descriptor-parity: H row — ${runtime} (${scope}) real descriptor output matches the independent oracle`, () => { + const { expected, actual } = comparePipelines(runtime, scope); + assert.ok(expected.size > 0, 'fixture produced no oracle output — test is vacuous'); + assertMapsIdentical(expected, actual); + }); +} + +// --------------------------------------------------------------------------- +// H7 — the harness compares filenames, not only content (meta) +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: H7 — a filename-only divergence fails the harness', (t) => { + const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS); + const targetDir = buildTargetDir(commandsGsd); + t.after(() => { + cleanup(root); + cleanup(targetDir); + }); + const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir }; + const expected = computeExpectedOutput('claude', agentsDir, ctx); + const actual = runRealDescriptorPath('claude', 'global', targetDir, ctx); + // Deliberately rename one actual-side entry — same bytes, different name. + const [firstName, firstContent] = [...actual.entries()][0]; + actual.delete(firstName); + actual.set(`RENAMED-${firstName}`, firstContent); + assert.throws( + () => assertMapsIdentical(expected, actual), + /filenames diverged/, + 'a renamed output file must fail the harness', + ); +}); + +// --------------------------------------------------------------------------- +// H8 — the harness can actually FAIL (mandatory, not optional) +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: H8 — a deliberately-wrong (but real, allowlisted) expected converter turns the harness RED', (t) => { + // Hermes's REAL capability.json declares convertClaudeAgentToHermesAgent. + // Feed the ORACLE a different, real, allowlisted converter name + // (convertClaudeAgentToCodexAgent) instead — simulating exactly the failure + // mode row H exists to catch: capability.json's declared converter silently + // diverging from the correct one. The REAL descriptor path is untouched and + // still uses hermes's real (correct) converter, so this proves: IF + // capability.json ever regressed to the wrong name, THIS harness's H row + // for hermes would go red exactly like this. + const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS); + const targetDir = buildTargetDir(commandsGsd); + t.after(() => { + cleanup(root); + cleanup(targetDir); + }); + const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir }; + const wrongExpected = computeExpectedOutput('hermes', agentsDir, ctx, 'convertClaudeAgentToCodexAgent'); + const realActual = runRealDescriptorPath('hermes', 'global', targetDir, ctx); + let threw = false; + let observedDiff = null; + try { + assertMapsIdentical(wrongExpected, realActual); + } catch (err) { + threw = true; + observedDiff = err.message; + } + assert.equal(threw, true, 'H8 FAILED: the harness did not go red for a deliberately-wrong expected converter'); + assert.match(observedDiff, /content diverged for gsd-(planner|plan-checker)\.md/, 'expected the content-diverged assertion to name the mismatched file'); + // Verbatim red-proof output for the record (see CHANGES report): + console.log(`H8 red-proof observed: ${observedDiff}`); +}); + +// --------------------------------------------------------------------------- +// I1-I3 — per-agent resolution context +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: I3 — deriveAgentName matches the pipeline exactly, including a no-.md-suffix boundary', () => { + assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.md'), 'gsd-planner'); + // Boundary: a filename with no trailing .md is returned unchanged (the + // regex has nothing to match) — matches `entry.name.replace(/\.md$/, '')`. + assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner'), 'gsd-planner'); + assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.MD'), 'gsd-planner.MD'); +}); + +test('agent-descriptor-parity: I2 — real descriptor path with no agentCtx is unaffected (converter-only)', (t) => { + const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS); + const targetDir = buildTargetDir(commandsGsd); + t.after(() => { + cleanup(root); + cleanup(targetDir); + }); + const runtimeArtifactLayout = require(path.join(LIB_DIR, 'runtime-artifact-layout.cjs')); + const realLayout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('claude', targetDir, 'global'); + const agentsKindEntry = realLayout.kinds.find((k) => k.kind === 'agents'); + const stagedDir = agentsKindEntry.stage({ name: 'full', skills: '*', agents: new Set() }); // no agentCtx + t.after(() => cleanup(stagedDir)); + const planner = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8'); + const original = fs.readFileSync(path.join(agentsDir, 'gsd-planner.md'), 'utf8'); + assert.equal(planner, original, 'no agentCtx must leave content byte-identical to source (converter:null == identity)'); +}); + +// --------------------------------------------------------------------------- +// J1-J4 — frontmatter extensions +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: J1 — claude effort is injected via applyAgentFrontmatterExtensions', () => { + const content = '---\nname: gsd-planner\ndescription: x\n---\n\nBody.\n'; + const viaShared = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-planner', targetDir: null }); + assert.match(viaShared, /^effort: /m, 'expected an effort: key to be injected for claude'); +}); + +test('agent-descriptor-parity: J2 — effort resolving to inherit writes NO effort: key at all, exercised via the REAL guard in applyAgentFrontmatterExtensions (#3533 trap row)', (t) => { + // #2875 Part 2 defect fix: a prior revision of this row asserted the DUMB + // half (injectEffortFrontmatter DOES emit the literal 'inherit' if called + // with it) and never called applyAgentFrontmatterExtensions at all — so + // deleting the `universalEffort !== 'inherit'` guard at + // runtime-artifact-conversion.cts:3504 left this row green. This revision + // writes a REAL .planning/config.json under targetDir (readGsdEffectiveEffortConfig + // walks up from targetDir looking for it — install-effort-resolver.cts) + // and calls the REAL applyAgentFrontmatterExtensions end to end, so removing + // that guard makes THIS assertion fail (verified: red with the guard + // removed, green with it restored — see CHANGES report). + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-j2-')); + t.after(() => cleanup(targetDir)); + fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(targetDir, '.planning', 'config.json'), + JSON.stringify({ effort: { agent_overrides: { 'gsd-inherit-agent': 'inherit' } } }), + ); + const content = '---\nname: gsd-inherit-agent\ndescription: x\n---\n\nBody.\n'; + const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-inherit-agent', targetDir }); + assert.doesNotMatch(out, /^effort:/m, 'an agent resolving to "inherit" must get no effort: key at all — not even effort: inherit'); + // Sanity: injectEffortFrontmatter itself is dumb and WOULD write the + // literal if called with it — the guard is applyAgentFrontmatterExtensions + // never calling it for 'inherit', which is exactly what the assertion above proves. + assert.match(runtimeArtifactConversion.injectEffortFrontmatter(content, 'inherit'), /^effort: inherit$/m); +}); + +test('agent-descriptor-parity: J3 — a runtime NOT declaring agentFrontmatterExtensions gets nothing injected', () => { + const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n'; + const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'opencode', agentName: 'gsd-plan-checker', targetDir: null }); + assert.equal(out, content, 'opencode declares no agentFrontmatterExtensions — output must be byte-identical to input'); +}); + +test('agent-descriptor-parity: J4 — disallowedTools injected only on a READONLY_AGENT_DISALLOWED_TOOLS hit', () => { + const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n'; + const hit = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-plan-checker', targetDir: null }); + assert.match(hit, /^disallowedTools: /m, 'gsd-plan-checker is a declared read-only agent — expected a disallowedTools hit'); + + const missContent = '---\nname: gsd-not-a-readonly-agent\ndescription: x\n---\n\nBody.\n'; + const miss = runtimeArtifactConversion.applyAgentFrontmatterExtensions(missContent, { runtime: 'claude', agentName: 'gsd-not-a-readonly-agent', targetDir: null }); + assert.doesNotMatch(miss, /^disallowedTools:/m, 'an agent absent from READONLY_AGENT_DISALLOWED_TOOLS must get no disallowedTools key'); +}); + +// --------------------------------------------------------------------------- +// J5-J8 — model-override resolution (kilo/opencode), single-sourced +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: J5 — explicit model_overrides[agent] wins (highest precedence)', () => { + const modelOverrides = { 'gsd-planner': 'anthropic/explicit-model' }; + const runtimeResolver = { resolve: () => ({ model: 'anthropic/tier-model' }) }; // would win if precedence were wrong + const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', modelOverrides, runtimeResolver); + assert.equal(result, 'anthropic/explicit-model'); +}); + +test('agent-descriptor-parity: J6 — falls back to the runtime tier resolver when no explicit override exists', () => { + const runtimeResolver = { resolve: (agentName) => (agentName === 'gsd-planner' ? { model: 'anthropic/tier-model' } : null) }; + const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, runtimeResolver); + assert.equal(result, 'anthropic/tier-model'); +}); + +test('agent-descriptor-parity: J7 — neither configured resolves to null (omit), never "" or the string "null"', () => { + const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, null); + assert.equal(result, null); + assert.notEqual(result, ''); + const contentWithoutModelOverride = installBin.convertClaudeToOpencodeFrontmatter( + '---\nname: gsd-planner\ndescription: x\ntools: Read\n---\n\nBody.\n', + { isAgent: true, modelOverride: result }, + ); + assert.doesNotMatch(contentWithoutModelOverride, /^model:/m, 'an omitted override must not appear as a model: key at all'); +}); + +test('agent-descriptor-parity: J8 — kilo and opencode both resolve model overrides through the REAL descriptor path (installAgentsKindStandalone), proving ONE shared resolution path, not a tautology', () => { + // #2875 Part 2 defect fix: a prior revision of this row called + // resolveAgentModelOverride TWICE with identical arguments and asserted + // equality — true of ANY pure function regardless of whether kilo/opencode + // are actually wired to it. This revision drives BOTH runtimes through the + // REAL production entry point with a REAL .planning/config.json + // model_overrides block and asserts BOTH staged outputs carry the SAME + // resolved model — which only happens if both are genuinely wired to the + // one shared installModelOverrideResolver.resolveAgentModelOverride. + const kiloOut = _stageWithModelOverride('kilo', J8_OVERRIDE_MODEL); + const opencodeOut = _stageWithModelOverride('opencode', J8_OVERRIDE_MODEL); + // J8_OVERRIDE_MODEL is a fixed constant: assert the exact expected + // frontmatter line, matched line-wise, rather than building a RegExp from + // an interpolated value (CodeQL js/incomplete-sanitization — a + // metachar-bearing value would silently widen the match). + const expectedModelLine = `model: ${J8_OVERRIDE_MODEL}`; + assert.ok( + kiloOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine), + 'kilo must apply the shared model override via the real descriptor path', + ); + assert.ok( + opencodeOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine), + 'opencode must apply the SAME shared model override via the real descriptor path', + ); +}); + +// --------------------------------------------------------------------------- +// J9-J10 — hermes branding converter +// --------------------------------------------------------------------------- + +test('agent-descriptor-parity: J9 — hermes branding converter is byte-identical to the shared generic branding-rewrite function, including \\bClaude Code\\b word-boundary semantics', () => { + const content = 'Claude Code and ClaudeCodeExtra and CLAUDE.md and .claude/foo and reClaude Code.\n'; + const viaSharedFn = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes'); + const viaNamedConverter = runtimeArtifactConversion.convertClaudeAgentToHermesAgent(content); + assert.equal(viaSharedFn, viaNamedConverter, 'the named converter must be a pure delegate to the generic branding-rewrite function'); + // \bClaude Code\b: neither "ClaudeCodeExtra" (no space/boundary between + // "Claude" and "Code") nor "reClaude Code" (no boundary between the 'e' of + // "re" and the 'C' of "Claude" — both word chars) satisfy the word-boundary + // requirement, so BOTH are left untouched; only the standalone occurrence is + // rewritten. + assert.match(viaSharedFn, /Hermes Agent and ClaudeCodeExtra and HERMES\.md and \.hermes\/foo and reClaude Code\./); +}); + +test('agent-descriptor-parity: J10 — the branding converter is descriptor-data-driven, not hardcoded to hermes strings', () => { + const content = 'Claude Code uses CLAUDE.md under .claude/.\n'; + // A runtime with NO brandingRewrites declared gets nothing rewritten. + assert.equal(runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'claude'), content); + // hermes (the only runtime with brandingRewrites AND no dedicated converter + // pre-#2875) gets ITS OWN declared rewrite table applied — proving the + // function reads the runtime's descriptor rather than a hermes-hardcoded literal. + const hermesOut = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes'); + assert.notEqual(hermesOut, content); + assert.match(hermesOut, /Hermes Agent uses HERMES\.md under \.hermes\/\./); +}); + +// --------------------------------------------------------------------------- +// K1 — migration completeness: every registry runtime with an `agents` kind +// is reachable from the REAL production entry point, not the (now-deleted) +// inline loop. +// --------------------------------------------------------------------------- + +/** + * bin/install.js's inline agent-staging loop and its `_DESCRIPTOR_AGENTS_RUNTIMES` + * gate were DELETED in #2875 Part 2 Task C — there is no longer a symbol to + * assert absent (a source-text check would violate `local/no-source-grep` and + * would prove nothing about runtime behavior anyway, per CLAUDE.md's + * "Behavioral tests are required"). Row K1 is instead proven the only way + * that is actually meaningful once the code is gone: for EVERY `role: + * "runtime"` capability in the REAL capability-registry that declares an + * `agents` kind (either scope), the REAL production entry point + * (`installRuntimeArtifacts` — which internally routes combinedFamilyInstall + * runtimes like kilo/opencode through `installOpencodeFamilyAgents`, #2875 + * Part 2 Task A) actually materializes agents/ on disk. If any runtime were + * still silently depending on the deleted inline loop, this call would write + * nothing to agents/ for it (the deleted code was the ONLY thing that used to + * write it for the seven runtimes migrated in this change) and the assertion + * below would fail. + */ +test('agent-descriptor-parity: K1 — every registry runtime declaring an agents kind is reachable from installRuntimeArtifacts (the inline loop is gone)', (t) => { + const runtimesWithAgentsKind = Object.entries(capabilityRegistry.runtimes || {}) + .filter(([, cap]) => { + const layout = cap.runtime && cap.runtime.artifactLayout; + if (!layout) return false; + const entries = [...(layout.global || []), ...(layout.local || [])]; + return entries.some((e) => e.kind === 'agents'); + }) + .map(([id]) => id); + + assert.ok(runtimesWithAgentsKind.length >= 7, 'sanity: expected at least the seven #2875 Part 2 runtimes to declare an agents kind'); + assert.ok(runtimesWithAgentsKind.includes('kimi-code'), 'kimi-code (found via golden fixture, not analysis) must be covered here'); + assert.ok(runtimesWithAgentsKind.includes('cline'), 'cline must be covered here'); + + for (const runtime of runtimesWithAgentsKind) { + const { commandsGsd, root } = buildSourceTree(SAMPLE_AGENTS); + const targetDir = buildTargetDir(commandsGsd); + t.after(() => { + cleanup(root); + cleanup(targetDir); + }); + const resolvedProfile = { name: 'full', skills: '*', agents: new Set() }; + const result = installEngine.installRuntimeArtifacts(runtime, targetDir, 'global', resolvedProfile, () => undefined, undefined); + const agentsKindEntry = result.kinds.find((k) => k.kind === 'agents'); + assert.ok(agentsKindEntry, `${runtime}: installRuntimeArtifacts reported no agents kind in the executed plan — it did not go through the descriptor path`); + const writtenFiles = fs.readdirSync(agentsKindEntry.destDir).filter((f) => f.endsWith('.md')); + assert.ok(writtenFiles.length > 0, `${runtime}: agents kind reported but nothing was actually written to ${agentsKindEntry.destDir}`); + } +}); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 4a0b6b03e..8d1c242a9 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -4041,9 +4041,18 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => { // ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ───────────────── describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { - test('VALID_CONVERTER_NAMES has exactly 27 entries (16 command/skill/workflow + 11 agent converters)', () => { + // #2875 Part 2 (the agents-bypass closure): 3 converters added — + // convertClaudeAgentToHermesAgent (data-driven Hermes branding converter, + // reads hostBehaviors.brandingRewrites) and convertClaudeToKiloFrontmatter / + // convertClaudeToOpencodeFrontmatter (kilo/opencode's agents-kind + // converters — shared by name with those runtimes' commands-kind entries, + // options-bag signature `(content, {isAgent, modelOverride})`). All three + // are genuinely new agent converters (not renamed/leftover), so the agent + // count grows from 11 to 14; the 16 command/skill/workflow converters are + // unchanged. + test('VALID_CONVERTER_NAMES has exactly 30 entries (16 command/skill/workflow + 14 agent converters)', () => { assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set'); - assert.strictEqual(VALID_CONVERTER_NAMES.size, 27, 'VALID_CONVERTER_NAMES must have exactly 27 entries, got: ' + VALID_CONVERTER_NAMES.size); + assert.strictEqual(VALID_CONVERTER_NAMES.size, 30, 'VALID_CONVERTER_NAMES must have exactly 30 entries, got: ' + VALID_CONVERTER_NAMES.size); }); test('VALID_CONVERTER_NAMES contains all expected converter names', () => { @@ -4078,6 +4087,12 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { 'convertClaudeAgentToQwenAgent', // #3384 — ZCode agent converter (strips mcp__* grants at install time). 'convertClaudeAgentToZcodeAgent', + // #2875 Part 2 (the agents-bypass closure) — data-driven Hermes branding + // converter, and the kilo/opencode agent converters (shared name with + // those runtimes' commands-kind entries). + 'convertClaudeAgentToHermesAgent', + 'convertClaudeToKiloFrontmatter', + 'convertClaudeToOpencodeFrontmatter', ]; for (const name of expected) { assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"'); diff --git a/tests/cline-install.test.cjs b/tests/cline-install.test.cjs index df7e1b81d..7667985f9 100644 --- a/tests/cline-install.test.cjs +++ b/tests/cline-install.test.cjs @@ -193,6 +193,16 @@ describe('Cline install (local)', () => { assert.ok(!fs.existsSync(settingsJson), 'settings.json must not be written for cline runtime'); }); + test('install writes agents/gsd-*.md locally (regression: agents dropped when the inline agent-staging loop was deleted, #2875 Part 2)', () => { + install(false, 'cline'); + const agentsDir = path.join(tmpDir, 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist after cline local install'); + const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(agentFiles.length > 0, 'cline local install must write at least one gsd-*.md agent file'); + const sample = fs.readFileSync(path.join(agentsDir, agentFiles[0]), 'utf8'); + assert.match(sample, /^---\r?\nname: /, 'cline agent frontmatter must be Cline-dialect (name + description only)'); + }); + test('installed engine files have no leaked .claude paths', () => { install(false, 'cline'); const engineDir = path.join(tmpDir, 'gsd-core'); diff --git a/tests/declarative-reference-augment.test.cjs b/tests/declarative-reference-augment.test.cjs index 9dce51d7e..aca252021 100644 --- a/tests/declarative-reference-augment.test.cjs +++ b/tests/declarative-reference-augment.test.cjs @@ -202,7 +202,45 @@ test('legitimate isAugment destructure/enumeration sites survive (not eliminated const file = path.join(__dirname, '..', 'bin', 'install.js'); const src = fs.readFileSync(file, 'utf8'); assert.ok(/isAugment/.test(src), 'isAugment must still be destructured from runtimeFlags() for non-conversion uses'); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded bin/install.js source, not adversarial input - assert.ok(/_DESCRIPTOR_AGENTS_RUNTIMES\s*=\s*new Set\(\[[^\]]*'augment'/.test(src), - 'augment must remain in _DESCRIPTOR_AGENTS_RUNTIMES (descriptor-driven agent layout)'); +}); + +// #2875 Part 2 (the agents-bypass closure): `_DESCRIPTOR_AGENTS_RUNTIMES` — the +// allow-list this test previously asserted augment's membership in — was +// deleted along with the inline agent-staging loop it gated. EVERY runtime is +// now descriptor-driven for agents, so allow-list membership is no longer the +// property that guarantees it; the property that DOES now guarantee it is +// that augment's OWN capability.json declares a real `agents` artifact kind +// (consumed by installRuntimeArtifacts/installAgentsKindStandalone) whose +// converter is augment's real converter function, not `null` (a `null` +// converter would raw-copy Claude-shaped agent bodies instead of converting +// them) and not a placeholder/typo'd name (capability-registry.test.cjs's +// VALID_CONVERTER_NAMES closed-enum guard already rejects an unknown name at +// load time; this test additionally pins that the RIGHT name is used). +test('augment capability.json declares a real, converter-bearing agents kind for both scopes (descriptor-driven agent layout)', () => { + const capFile = path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json'); + const cap = JSON.parse(fs.readFileSync(capFile, 'utf8')); + const layout = cap.runtime.artifactLayout; + for (const scope of ['global', 'local']) { + const entries = layout[scope] || []; + const agentsKind = entries.find((e) => e.kind === 'agents'); + assert.ok(agentsKind, `augment capability.json artifactLayout.${scope} must declare an 'agents' kind`); + assert.strictEqual( + agentsKind.converter, + 'convertClaudeAgentToAugmentAgent', + `augment ${scope} agents kind must use the real convertClaudeAgentToAugmentAgent converter, not null/unset`, + ); + } +}); + +test('_DESCRIPTOR_AGENTS_RUNTIMES no longer exists as a live declaration — the descriptor is authoritative for every runtime, not an allow-listed subset', () => { + const file = path.join(__dirname, '..', 'bin', 'install.js'); + const src = fs.readFileSync(file, 'utf8'); + // A residual mention in a historical // comment (documenting the #2875 + // deletion itself) is fine; a live `const _DESCRIPTOR_AGENTS_RUNTIMES =` + // declaration means the allow-list crept back in. + assert.ok( + !/(?:const|let|var)\s+_DESCRIPTOR_AGENTS_RUNTIMES\s*=/.test(src), + '_DESCRIPTOR_AGENTS_RUNTIMES was deleted by #2875 Part 2 — a live re-declaration means a runtime-specific ' + + 'agents allow-list crept back in, undoing the migration this test guards', + ); }); diff --git a/tests/executed-plan.test.cjs b/tests/executed-plan.test.cjs index 650c8f37f..7f845cf5d 100644 --- a/tests/executed-plan.test.cjs +++ b/tests/executed-plan.test.cjs @@ -610,7 +610,17 @@ describe('installRuntimeArtifacts — E4: kilo (second family member)', () => { result, undefined, 'E4: kilo, the SECOND combined-family runtime, must ALSO return a plan — E3 is not a one-runtime special case', ); - assert.deepStrictEqual(result.kinds.map((k) => k.kind).sort(), ['commands', 'skills']); + // 'agents' was added here deliberately by #2875 Part 2 Task A + // (installAgentsKindStandalone, install-engine.cts:1614-1618): the + // combined-family (opencode/kilo) executed plan now also reports the + // agents kind it stages via installAgentsKindStandalone, mirroring the + // generic layout-driven loop's own top-level shape (install-engine.cts:1627-1637). + // This test was written under #2874 (Phase 5), before that kind was + // wired in — its expected list was never updated. Kilo's resolved layout + // declares an `agents` kind, so a correct plan MUST include it; a + // ['commands', 'skills']-only expectation encoded the pre-#2875 shape, + // not a real contract. + assert.deepStrictEqual(result.kinds.map((k) => k.kind).sort(), ['agents', 'commands', 'skills']); }); }); @@ -775,6 +785,44 @@ describe('installRuntimeArtifacts — E12: executed plan key set is locked', () // ─── F. Fs adapter seam — F4-F6 ─────────────────────────────────────────────── +/** + * Build a fs object that implements EVERY InstallFsAdapter method by + * delegating to real `node:fs` (mirroring install-fs-adapter.cts's own + * REAL_ADAPTER), then applies `overrides` on top. buildGuardedAdapter + * (install-fs-adapter.cts) now throws for any method an injected partial + * omits (the module doc's "PARTIAL-ADAPTER TRAP" fix), so an end-to-end test + * that drives a REAL install against a REAL destDir (F4/I2/I5 below — these + * need real command/agent source content actually copied) while + * intercepting only one or two specific calls needs a COMPLETE fake that + * only fakes what it overrides — exactly the "documented, intended usage" + * install-fs-adapter.cts's own module doc calls out, as opposed to + * `createFakeInstallFs`'s fully in-memory store (used where the test itself + * controls all content, e.g. F2/F5/F6). + */ +function createRealDelegatingFs(overrides = {}) { + const base = { + existsSync: (p) => fs.existsSync(p), + mkdirSync: (p, opts) => fs.mkdirSync(p, opts), + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- delegate for a fake-adapter method, not test cleanup + rmSync: (p, opts) => fs.rmSync(p, opts), + readdirSync: (p, opts) => (opts ? fs.readdirSync(p, opts) : fs.readdirSync(p)), + readFileSync: (p, encoding) => (encoding ? fs.readFileSync(p, encoding) : fs.readFileSync(p)), + writeFileSync: (p, data, opts) => fs.writeFileSync(p, data, opts), + copyFileSync: (src, dest) => fs.copyFileSync(src, dest), + cpSync: (src, dest, opts) => fs.cpSync(src, dest, opts), + lstatSync: (p) => fs.lstatSync(p), + realpathSync: (p) => fs.realpathSync(p), + unlinkSync: (p) => fs.unlinkSync(p), + rmdirSync: (p) => fs.rmdirSync(p), + symlinkSync: (target, p) => fs.symlinkSync(target, p), + readlinkSync: (p) => fs.readlinkSync(p), + openSync: (p, flags) => fs.openSync(p, flags), + readSync: (fd, buffer, offset, length, position) => fs.readSync(fd, buffer, offset, length, position), + closeSync: (fd) => fs.closeSync(fd), + }; + return { ...base, ...overrides }; +} + describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup still runs', () => { test('adapter errors propagate, cleanup still runs', (t) => { const configDir = createTempDir('gsd-f4-augment-'); @@ -782,7 +830,7 @@ describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup stil sandboxHome(t, configDir); let capturedCleanupDir; - const fakeFs = { + const fakeFs = createRealDelegatingFs({ writeFileSync: (p, data, opts) => { if (String(p).includes('gsd-cmd-rewrites-') && capturedCleanupDir === undefined) { capturedCleanupDir = path.dirname(p); @@ -794,7 +842,7 @@ describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup stil err.code = 'EACCES'; throw err; }, - }; + }); assert.throws( () => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }), @@ -834,37 +882,35 @@ describe('installRuntimeArtifacts — F5: fake existsSync drives the same branch }); }); -describe('installRuntimeArtifacts — F6: incomplete adapter falls back to real fs, never silently no-ops', () => { - test('incomplete adapter fails loudly (never silently skips the write)', (t) => { - // install-fs-adapter.cts's documented PARTIAL-ADAPTER TRAP: withInstallFs - // merges the injected partial OVER the real adapter — any method the - // partial omits resolves to REAL node:fs, silently. This pins the - // dangerous alternative it guards against: an omitted method must never - // degrade into a silent no-op (skipping the operation, pretending - // success) — it must actually execute, for real. - const configDir = createTempDir('gsd-f6-claude-'); - t.after(() => cleanup(configDir)); - sandboxHome(t, configDir); +describe('installRuntimeArtifacts — F6: incomplete adapter fails loudly, never silently falls back to real fs', () => { + // #2875 REVERSES this row's earlier pinned contract ("falls back to real + // fs, never silently no-ops"). That contract was itself the defect + // buildGuardedAdapter closes (install-fs-adapter.cts's "PARTIAL-ADAPTER + // TRAP" doc comment): merging an injected partial OVER the real adapter + // meant any method the partial omitted was silently REAL `node:fs` — e.g. + // user-artifact-staging.cts's `stageUserArtifacts` calling + // `installFs().rmSync(entryDir)` unconditionally, where a test fake + // missing `rmSync` would silently delete the real + // `/.gsd-staging/` on disk. Falling through to real fs is + // exactly how a fake-adapter test can end up performing real, uncontrolled + // IO — the bug, not a feature. The guarded contract instead throws + // immediately, naming the missing method, the moment the exercised path + // reaches it: never a silent no-op AND never a silent real-fs write. + test('incomplete adapter fails loudly (never silently skips the write)', () => { + const configDir = path.join(os.tmpdir(), `gsd-f6-must-not-exist-${crypto.randomUUID()}`); - let realMkdirCalls = 0; - // Deliberately incomplete: only mkdirSync is overridden (to prove this - // partial is genuinely merged over real fs, not a full copy of it) — - // every other method (existsSync/readdirSync/writeFileSync/ - // copyFileSync/cpSync/...) is omitted entirely. + // Deliberately incomplete: only mkdirSync is implemented, to prove the + // FIRST other method the call path reaches throws immediately instead of + // silently degrading to real fs or a no-op. const incompleteFs = { - mkdirSync: (p, opts) => { realMkdirCalls++; return fs.mkdirSync(p, opts); }, + mkdirSync: () => undefined, }; - const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, undefined, { fs: incompleteFs }); - - assert.notStrictEqual(result, undefined, 'F6: an incomplete adapter must not silently produce no result'); - assert.ok(realMkdirCalls > 0, 'F6 test precondition: mkdirSync must have been called'); - const skillsKind = result.kinds.find((k) => k.kind === 'skills'); - assert.ok(skillsKind, 'F6 precondition: claude global writes a skills kind'); - assert.ok( - fs.existsSync(skillsKind.destDir) && fs.readdirSync(skillsKind.destDir).length > 0, - 'F6: every method the incomplete adapter omitted fell back to REAL fs and actually wrote real ' + - 'content — an incomplete fake never silently no-ops the operations it does not implement', + assert.throws( + () => installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, undefined, { fs: incompleteFs }), + (err) => /does not implement it/.test(err.message) && /PARTIAL-ADAPTER TRAP/.test(err.message), + 'F6: an incomplete adapter must fail loudly, naming the missing method, the moment the call path ' + + 'reaches a method it does not implement — never silently no-op or fall back to real fs', ); }); }); @@ -1017,19 +1063,15 @@ describe('installRuntimeArtifacts — I2: failed cleanup is visible, not silent' t.after(() => cleanup(configDir)); sandboxHome(t, configDir); - const fakeFs = { + const fakeFs = createRealDelegatingFs({ rmSync: (p, opts) => { if (String(p).includes('gsd-cmd-rewrites-')) { throw new Error('I2: simulated cleanup failure'); } - // This is a fake fs-adapter METHOD delegating to real fs for paths - // it does not intentionally poison, not a test's own directory- - // cleanup call (which still goes through t.after(() => cleanup(...)) - // above). // eslint-disable-next-line local/no-raw-rmsync-in-tests -- delegate, not test cleanup return fs.rmSync(p, opts); }, - }; + }); const result = installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }); @@ -1080,7 +1122,7 @@ describe('installRuntimeArtifacts — I5: rewrite failure mid-plan cleans up and sandboxHome(t, configDir); let capturedCleanupDir; - const fakeFs = { + const fakeFs = createRealDelegatingFs({ mkdirSync: (p, opts) => { if (String(p).includes('gsd-profile-runtime-skills-')) { throw new Error('I5: simulated skills-stage failure AFTER commands already rewrote+registered a cleanup dir'); @@ -1094,7 +1136,7 @@ describe('installRuntimeArtifacts — I5: rewrite failure mid-plan cleans up and } fs.writeFileSync(p, data, opts); }, - }; + }); assert.throws( () => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }), diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 07412e34f..c08efe07d 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -1330,10 +1330,21 @@ describe('#1924: profile-user.md backup path must be outside gsd-core/', () => { }); }); -// ─── Test 4: preserveUserArtifacts helper exported from install.js ──────────── +// ─── Test 4: user artifacts survive a wipe via the durable staging path ────── +// +// preserveUserArtifacts/restoreUserArtifacts (an in-memory Map, the #1924 +// fix's original mechanism) were retired by #2875 — nothing calls them any +// more, so a `typeof preserveUserArtifacts === 'function'` assertion would +// guard a function that never runs: vacuous. #1924's actual point was never +// "this specific helper is exported" — it was "user artifacts survive a +// reinstall wipe". This asserts that SAME property through the durable +// on-disk staging path that replaced the in-memory one, including surviving +// a crash BETWEEN the wipe and the restore (#1874-F19) — a stronger +// guarantee than the retired helper ever gave, and one a plain in-process +// round-trip would not prove. -describe('#1924: preserveUserArtifacts helper exists in install.js', () => { - test('install.js exports preserveUserArtifacts function', () => { +describe('#1924: user artifacts survive a wipe via the durable staging path (install.js exports)', () => { + test('install.js exports the staging primitives, and content staged through them survives a destDir wipe + crash', (t) => { // Set GSD_TEST_MODE so require() reaches the module.exports block const origMode = process.env.GSD_TEST_MODE; process.env.GSD_TEST_MODE = '1'; @@ -1348,11 +1359,77 @@ describe('#1924: preserveUserArtifacts helper exists in install.js', () => { } } - assert.strictEqual( - typeof mod.preserveUserArtifacts, - 'function', - 'install.js must export preserveUserArtifacts helper for testability' - ); + for (const name of ['stageUserArtifacts', 'restoreStagedUserArtifacts', 'discardStagedUserArtifacts', 'recoverOrphanedUserArtifacts']) { + assert.strictEqual( + typeof mod[name], + 'function', + `install.js must export ${name} — the mechanism that now makes the #1924 durability property testable`, + ); + } + + // user-artifact-staging.cts's recoverOrphanedUserArtifacts re-confines the + // record's destDir against configDir (module doc "Confinement"/E2) before + // recovering anything: `path.relative(configHome, record.destDir)` must + // resolve to a path that never leaves configHome, exactly matching every + // real call site (bin/install.js always stages/recovers a destDir that is + // a SUBDIRECTORY of configDir). destDir must therefore live under + // configDir here too — two unrelated sibling temp dirs (the smoke script's + // shortcut) trip E2's outside-confinement refusal and recovered stays + // empty, which is what silently diverged the smoke check from this suite. + // + // Second, separate divergence: the owner-liveness guard + // (recoverOrphanedUserArtifacts) skips an entry entirely, reason + // 'owner-still-live', whenever the record's `runId` names a currently- + // alive process — and `stageUserArtifacts` defaults `runId` to + // `String(process.pid)`, i.e. THIS test process, which is alive for the + // whole in-process round trip. A real crashed run has a genuinely DEAD + // pid; simulate that explicitly (same convention + // tests/user-artifact-staging.test.cjs's C15 uses) or recovery always + // reports this as "not an orphan yet" and never restores anything. + const configDir = createTempDir('gsd-1924-staging-cfg-'); + const destDir = path.join(configDir, 'skills'); + try { + fs.mkdirSync(destDir, { recursive: true }); + const content = '# My Profile\n\nSurvives via durable staging (#2875).\n'; + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), content); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const deadPid = '999999'; + mod.stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: deadPid }); + + // Simulate the #1874-F19 crash window: the wipe ran, the process died + // before any restore. #2875 AC: inject via genuine `fs`-method + // monkeypatching (CLAUDE.md §4 / the established idiom in + // planning-lock-mkdir-failure-1884.test.cjs) rather than deleting + // destDir out-of-band — `t.mock.method` auto-restores the original + // after this test (no manual try/finally; CONTRIBUTING.md bans + // try/finally in test bodies), and the mock delegates to the captured + // original so destDir genuinely vanishes exactly as production's own + // `fs.rmSync(destDir, {recursive:true})` would — the injected crash + // boundary is "execution never proceeds past this call to any + // restore", not a thrown error from the wipe itself. The actual + // removal is driven through `helpers.cleanup()` (never a raw + // `fs.rmSync` in a test body — `local/no-raw-rmsync-in-tests`), which + // reads the SAME mutated `fs.rmSync` property (module singleton), so + // the mock still observes and performs the wipe. + const realRmSync = fs.rmSync; + t.mock.method(fs, 'rmSync', (targetPath, opts) => realRmSync.call(fs, targetPath, opts)); + cleanup(destDir); + assert.ok(!fs.existsSync(path.join(destDir, 'USER-PROFILE.md'))); + + // Recovery — not an ordinary restore call — is what must bring it + // back, proving the property survives a crash, not merely an + // in-process round-trip. + const result = mod.recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.strictEqual(result.recovered.length, 1); + assert.strictEqual( + fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), + content, + 'user artifact content must survive the wipe, byte-identical, via the durable staging path', + ); + } finally { + cleanup(destDir); + cleanup(configDir); + } }); }); }); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index bd5dcbe3a..bcdac356e 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -34,13 +34,19 @@ const { MANIFEST_NAME, installerEnv, stripAnsi, + runMinimalInstall, } = require('./helpers/install-shared.cjs'); const { installRuntimeArtifacts, installOpencodeFamilySkills, + _resolveUserArtifactStagingRoot, } = require('../gsd-core/bin/lib/install-engine.cjs'); +const { + stageUserArtifacts, +} = require('../gsd-core/bin/lib/user-artifact-staging.cjs'); + const { parseRuntimeInput, allRuntimes, @@ -2225,21 +2231,40 @@ describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix // ─── Fix 2 regression: cline local scope emits no skills ───────────────────── // -// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. +// resolveRuntimeArtifactLayout('cline', dir, 'local') must return no SKILLS +// kind (local commands are embedded in .clinerules, never materialized as +// skill files — hostBehaviors.localCommandsViaRules). // installRuntimeArtifacts('cline', dir, 'local') must not write any skills. +// +// #2875 Part 2 defect fix (cline-local agents-drop regression): local now +// ALSO declares an `agents` kind (capabilities/cline/capability.json) — the +// deleted inline agent-staging loop used to serve cline-local's agents/ +// unconditionally; closing it without this local descriptor entry silently +// dropped them. `kinds.length === 0` was true only by coincidence of the +// separate skills gap this describe block's title names; it is no longer +// true now that the actual (agents) regression is fixed. See +// tests/agent-descriptor-parity.test.cjs's cline (local) H row for the +// byte-parity proof and tests/cline-install.test.cjs's local-install +// regression test for the end-to-end proof. describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { - test('cline local: kinds.length === 0 (no skills for local scope)', () => { + test('cline local: no skills kind (skills for local scope are embedded in .clinerules, never materialized)', () => { const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); - assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); + const kindNames = layout.kinds.map((k) => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents'], 'cline local must declare only the agents kind — no skills kind'); }); - test('cline global: kinds.length === 1 (skills kind)', () => { + test('cline global: kinds.length === 2 (skills + agents kind, #2875 Part 2)', () => { const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); - assert.strictEqual(layout.kinds[0].kind, 'skills'); + // #2875 Part 2 (the agents-bypass closure): cline gained a descriptor-driven + // `agents` kind entry (capabilities/cline/capability.json), closing the + // inline-agent-loop bypass that used to serve it — see + // tests/agent-descriptor-parity.test.cjs's H2 row for the byte-parity proof. + assert.strictEqual(layout.kinds.length, 2, 'cline global must have 2 kinds (skills + agents)'); + const kindNames = layout.kinds.map((k) => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents', 'skills']); }); test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { @@ -7130,3 +7155,57 @@ describe('installRuntimeArtifacts — G3: adapter calling-convention regression ); }); }); + +// --------------------------------------------------------------------------- +// #2875 (epic #2866 Phase 6): User Artifact Staging — call-site integration. +// (.gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md) +// +// C7 (anti-inertness): recovery must be reachable from a REAL `bin/install.js` +// run, not merely callable — the #1879-F15 failure mode this whole phase +// exists to avoid. This spawns the real installer twice against the SAME +// sandbox HOME, with an orphaned staged artifact manually planted between the +// two runs (simulating a prior run that died between the wipe and its own +// restore/discard), and asserts the SECOND real installer invocation recovers +// it — proving the wiring in bin/install.js's `install()`, not just that the +// module's own function works when called directly. +// --------------------------------------------------------------------------- + +describe('#2875: user-artifact-staging — call-site integration (C7 anti-inertness)', () => { + test('an orphaned USER-PROFILE.md staged under the real gsd-core destDir is recovered by a subsequent real install() run', (t) => { + const first = runMinimalInstall({ runtime: 'claude', scope: 'global' }); + t.after(() => cleanup(first.root)); + + const gsdCoreDir = path.join(first.configDir, 'gsd-core'); + const profilePath = path.join(gsdCoreDir, 'USER-PROFILE.md'); + const customContent = '# My Profile\n\nOrphaned content from a crashed install run.\n'; + fs.writeFileSync(profilePath, customContent, 'utf8'); + + // Manually plant the orphan: stage USER-PROFILE.md durably (the commit + // point — record.json — lands), then simulate the crash by deleting the + // file WITHOUT restoring or discarding. This is exactly the state a + // process death between the wipe and the restore leaves behind. + const stagingRoot = _resolveUserArtifactStagingRoot(first.configDir); + const staged = stageUserArtifacts(gsdCoreDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + assert.deepEqual(staged.names, ['USER-PROFILE.md'], 'precondition: the orphan really did stage'); + fs.unlinkSync(profilePath); + assert.ok(!fs.existsSync(profilePath), 'precondition: the file is genuinely gone before the second run'); + + // Second REAL install, same HOME. If recovery is wired at a reachable + // production entry point, USER-PROFILE.md is repopulated with the + // orphaned content BEFORE the ordinary preserve/wipe/restore cycle runs + // (which has nothing to preserve on its own — the file was deleted, not + // merely staged, before this run started). + runMinimalInstall({ runtime: 'claude', scope: 'global', root: first.root }); + + assert.ok(fs.existsSync(profilePath), 'C7: USER-PROFILE.md must be recovered by the second real install run'); + assert.equal( + fs.readFileSync(profilePath, 'utf8'), + customContent, + 'recovered content must be byte-identical to the orphaned staged copy', + ); + + // The staging entry is consumed by the recovery step itself. + const entries = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot) : []; + assert.equal(entries.length, 0, 'the orphan is discarded once recovered — not left for a third run to find again'); + }); +}); diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index bcf61f381..22ec7a284 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -23,8 +23,28 @@ const { copyWithPathReplacement, installCodexConfig, _copyStaged, + _resolveUserArtifactStagingRoot: _installJsResolveUserArtifactStagingRoot, + _tryResolveUserArtifactStagingRoot: _installJsTryResolveUserArtifactStagingRoot, + install, + uninstall, } = require('../bin/install.js'); +// #2875 (epic #2866 Phase 6): user-artifact-staging confinement rows (E1-E5). +// Top-level (not inside any of the folded `__foldDescribe` sections below, +// which each scope their own requires to their own closure). +const { + assertDestWithinConfigHome: _uasAssertDestWithinConfigHome, +} = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs'); +const { + _resolveUserArtifactStagingRoot, + _tryResolveUserArtifactStagingRoot, +} = require('../gsd-core/bin/lib/install-engine.cjs'); +const { + recoverOrphanedUserArtifacts, + stageUserArtifacts, + restoreStagedUserArtifacts, +} = require('../gsd-core/bin/lib/user-artifact-staging.cjs'); + // --------------------------------------------------------------------------- // copyWithPathReplacement // --------------------------------------------------------------------------- @@ -2284,14 +2304,28 @@ describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest } }); - // Documented edge case: a broken symlink (target missing) is silently passed by - // both the default and opt-in paths. fs.existsSync follows the link and returns - // false, so the component loop terminates before the symlink check fires. This is - // pre-existing behavior — the fix preserves it. Subsequent mkdir may then fail or - // create the path through the resolved target; that's the caller's responsibility, - // not the symlink-escape guard's. Test pins the current behavior so any future - // change (e.g. switching to lstatSync for existence) is intentional. - test('broken symlink: silently passed (current behavior, preserved by fix)', (t) => { + // #2875 defect fix — REVERSES the previously-pinned "silently passed" contract + // this test used to name. The old reasoning ("existsSync(cursor) follows the + // link -> false -> loop terminates before the symlink check ever fires; the + // caller's subsequent mkdir/write is responsible for whatever happens next") + // no longer holds: an adversarial reviewer showed the "caller's responsibility" + // it rested on is unenforceable in practice — user-artifact-staging.cts's + // recovery path reads a staged file NAME out of an attacker-influenceable + // on-disk record (`record.json`) and writes through it without a human in the + // loop to notice a bad write; a dangling symlink planted at that destination + // (e.g. `/USER-PROFILE.md -> /authorized_keys`) let the + // actual copy/write follow it and land attacker-chosen content OUTSIDE the + // install root, reproduced end-to-end. hasExistingSymlinkBetween now probes + // each path segment with `lstatSync` FIRST (see install-engine.cts's own doc + // comment on this change), which — unlike `existsSync` — never follows a + // symlink and succeeds for a dangling one, so a dangling symlink is now + // correctly seen AS a symlink component instead of "nothing here". The guard's + // promise is now: a dangling symlink anywhere on the path between root and + // destDir is refused exactly like a live one — default refuses outright, + // opt-in attempts to follow it (`realpathSync`) and, finding no target, + // refuses too (fail-closed on a broken symlink, same posture used elsewhere in + // this function for a `realpathSync` failure). + test('broken symlink: now refused by both the default and opt-in paths (#2875 fix)', (t) => { const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-broken-')); try { const danglingLink = path.join(configHome, 'skills'); @@ -2304,18 +2338,19 @@ describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest } const destDir = path.join(danglingLink, 'gsd-foo'); - // existsSync(danglingLink) follows the link → false → loop returns false early. - // Same behavior with and without opt-in. Test documents this so a future - // refactor (e.g. lstatSync-based existence) is a deliberate behavior change. + // lstatSync(danglingLink) succeeds (it IS a symlink, just a dangling one) — + // the segment loop now sees it as a symlink component and refuses. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir), - false, - 'broken symlink: component loop terminates early (existsSync follows link → false)', + true, + 'broken symlink: default path now refuses — lstatSync sees the dangling symlink as a symlink component', ); + // Opt-in attempts to follow it via realpathSync, which throws ENOENT for a + // missing target — refused (fail-closed), not silently passed through. assert.strictEqual( hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), - false, - 'broken symlink with opt-in: same early-termination behavior', + true, + 'broken symlink with opt-in: realpathSync on a dangling target fails, so this refuses too (fail-closed)', ); } finally { try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ } @@ -2499,3 +2534,649 @@ describe('_installNativePluginIfDeclared write-confinement', () => { } }); }); + +// --------------------------------------------------------------------------- +// #2875 (epic #2866 Phase 6): User Artifact Staging confinement — E1-E5 +// (.gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md +// "E. Confinement"). Every row reuses assertDestWithinConfigHome / +// hasExistingSymlinkBetween rather than a bespoke check — see +// _resolveUserArtifactStagingRoot's own doc comment (install-engine.cts) and +// user-artifact-staging.cts's module doc "Confinement". +// --------------------------------------------------------------------------- + +describe('#2875: user-artifact-staging confinement (E1-E5)', () => { + test('E1: the staging root resolves through assertDestWithinConfigHome — an escaping subpath is refused by the SAME guard _resolveUserArtifactStagingRoot uses', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e1-')); + try { + // The real staging subpath ('.gsd-staging/user-artifacts') is a fixed + // literal that can never escape — so this asserts the PROPERTY the + // call site depends on directly against the same primitive, rather + // than trying to force an unreachable escape through the real API. + assert.throws( + () => _uasAssertDestWithinConfigHome(configDir, path.join('..', '..', 'etc', 'staging')), + /escap|strict subpath|configHome/i, + ); + // The real call resolves cleanly and stays confined. + const stagingRoot = _resolveUserArtifactStagingRoot(configDir); + assert.ok(path.resolve(stagingRoot).startsWith(path.resolve(configDir) + path.sep)); + } finally { + cleanup(configDir); + } + }); + + test('E2: recovery refuses a record naming a destDir outside confinement — never writes outside it', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-outside-')); + try { + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'attackerentry0000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'pwned.md'), 'attacker-controlled content'); + // The record is attacker-influenced data — it names a destDir OUTSIDE + // configDir entirely. + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: outside, names: ['pwned.md'], timestamp: new Date().toISOString() }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'destDir-outside-confinement'); + assert.ok(!fs.existsSync(path.join(outside, 'pwned.md')), 'must never write outside confinement'); + } finally { + cleanup(configDir); + cleanup(outside); + } + }); + + test('E3: ..-traversal in a staged file name is rejected', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e3-')); + try { + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const entryDir = path.join(stagingRoot, 'traversalentry000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['../../../etc/passwd'], timestamp: new Date().toISOString() }), + ); + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'traversal name must never be restored'); + // Neither the previous assertion (checked a path one level further up + // than production ever resolves, so it could never fail) nor a naive + // `resolve(destDir, name)` check (which, on a Linux runner, resolves to + // the REAL /etc/passwd — a file that pre-exists on disk regardless of + // whether this guard works, so `!existsSync(...)` fails unconditionally + // and proves nothing) exercises what the guard actually does. Trace the + // real call order in recoverOrphanedUserArtifacts: `srcPath = + // assertDestWithinConfigHome(filesDir, name)` is computed and throws + // FIRST — filesDir (`//files`) is nested several + // levels below configDir, and `../../../etc/passwd` resolved against it + // still escapes filesDir's own root, so this throw fires before + // `destPath` (against confinedDestDir) is ever computed and before any + // read/write is attempted. The observable, platform-independent proof + // that the traversal was rejected — not merely that some unrelated + // system file didn't get overwritten — is that destDir's own directory + // listing stays exactly as this test left it: no file materialized + // there via the traversal name. + assert.deepStrictEqual( + fs.readdirSync(destDir), [], + 'a rejected traversal name must never result in any file being written into destDir', + ); + } finally { + cleanup(configDir); + } + }); + + test('E4: a symlinked staging root is refused — same refusal as the existing dest guard', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-')); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-elsewhere-')); + try { + // Pre-create .gsd-staging as a symlink pointing outside configDir — + // exactly the threat hasExistingSymlinkBetween's root-symlink refusal + // (install-engine.cts) already covers for every other write on this + // call tree. + fs.symlinkSync(elsewhere, path.join(configDir, '.gsd-staging')); + assert.throws( + () => _resolveUserArtifactStagingRoot(configDir), + /symlink/i, + ); + } finally { + cleanup(configDir); + cleanup(elsewhere); + } + }); + + test('E5: a NUL byte in a staged file name is rejected', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e5-')); + try { + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const entryDir = path.join(stagingRoot, 'nulbyteentry00000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE\u0000.md'], timestamp: new Date().toISOString() }), + ); + let result; + assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); }); + assert.equal(result.recovered.length, 0, 'NUL-byte name must never be restored'); + } finally { + cleanup(configDir); + } + }); + + // #2875 defect fix: C2's "never overwrite" guard was `existsSync`-based, + // which FOLLOWS symlinks and reports `false` for a DANGLING one — invisible + // to the guard, so it never refused. A dangling symlink AT the recovered + // destination let `copyFileSync`/`symlinkSync` (which DO follow it) write + // outside `configDir`. + test('E6: a dangling symlink at the recovered destination is treated as already-present, never written through', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-cfg-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-outside-')); + try { + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'orphaned-content'); + // user-artifact-staging.cts's owner-liveness guard (recoverOrphanedUserArtifacts) + // treats a record whose `runId` belongs to a currently-live process as + // "not an orphan yet" and skips the ENTIRE entry with reason + // 'owner-still-live' — before ever reaching the per-name + // dest-already-present check this test pins. `stageUserArtifacts` + // defaults `runId` to `String(process.pid)`, i.e. THIS test process, + // which is trivially alive for the whole duration of this in-process + // test. A real crashed run has a genuinely DEAD pid, so simulating one + // here requires an explicit dead `runId` — same convention + // tests/user-artifact-staging.test.cjs's C15 already establishes. + const deadPid = '999999'; + stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: deadPid }); + cleanup(path.join(destDir, 'USER-PROFILE.md')); + + const outsideTarget = path.join(outside, 'authorized_keys'); + fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md')); + assert.ok(!fs.existsSync(outsideTarget), 'the symlink target must not exist — this is the DANGLING case'); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'must refuse — the dangling symlink counts as already-present'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'dest-already-present'); + assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget'); + assert.ok(fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(), 'the dangling symlink itself is left untouched'); + } finally { + cleanup(configDir); + cleanup(outside); + } + }); + + // #2875 defect fix: restoreStagedUserArtifacts had no guard at all against + // a dangling symlink at the destination — the identical hole as E6. + test('E7: restoreStagedUserArtifacts refuses a dangling symlink at the destination', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-cfg-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-outside-')); + try { + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'current-content'); + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot); + cleanup(path.join(destDir, 'USER-PROFILE.md')); + + const outsideTarget = path.join(outside, 'authorized_keys'); + fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md')); + + restoreStagedUserArtifacts(destDir, staged); + + assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget'); + assert.ok( + fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(), + 'the dangling symlink itself is left untouched, not overwritten', + ); + } finally { + cleanup(configDir); + cleanup(outside); + } + }); + + // #2875 defect fix: assertDestWithinConfigHome is pure lexical path math + // and cannot see a symlinked ANCESTOR directory between configDir and a + // recorded destDir — only hasExistingSymlinkBetween's component-by- + // component walk can. Recovery previously never applied it to the + // recorded destDir at all — this is E2 STRENGTHENED: E2 above only covers + // a destDir that is lexically outside configDir entirely, which + // assertDestWithinConfigHome alone already refused; this row covers the + // case that actually failed before this fix. + test('E8: recovery refuses a destDir reached only through a symlinked ANCESTOR directory', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-cfg-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-outside-')); + try { + fs.symlinkSync(outside, path.join(configDir, 'linkdir')); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'e8entry00000000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'attacker-controlled content'); + // The record names a destDir that is LEXICALLY inside configDir + // (assertDestWithinConfigHome alone would accept it) but only + // reachable by walking through the `linkdir` symlink to `outside`. + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ + destDir: path.join(configDir, 'linkdir', 'sub'), + names: ['USER-PROFILE.md'], + timestamp: new Date().toISOString(), + }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'must refuse — destDir is reached only through a symlinked ancestor'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'destDir-symlink-escape'); + assert.ok( + !fs.existsSync(path.join(outside, 'sub', 'USER-PROFILE.md')), + 'must never write outside configDir via the symlinked ancestor', + ); + } finally { + cleanup(configDir); + cleanup(outside); + } + }); + + // #2875 defect fix: `names` accepted any non-escaping subpath, including + // one containing a path separator, contrary to this module's flat-name + // contract (every real caller stages exactly one flat filename) — the + // module doc previously claimed separator names were already rejected; + // they were not. + test('E9: a staged/recorded name containing a path separator is rejected, never nested under destDir', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e9-cfg-')); + try { + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'e9entry000000000'); + fs.mkdirSync(path.join(entryDir, 'files', 'nested'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'nested', 'x.md'), 'should never land'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['nested/x.md'], timestamp: new Date().toISOString() }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'a separator-containing name must never be restored'); + assert.ok(!fs.existsSync(path.join(destDir, 'nested', 'x.md')), 'must never create a nested subpath under destDir'); + } finally { + cleanup(configDir); + } + }); + + // #2875 defect fix (security — source-side symlink escape): the ancestor- + // symlink guard (E8) covered only `destDir`. A real `entryDir` whose + // `files` CHILD is a symlink to an unrelated victim directory (e.g. + // `/etc`, `~/.ssh`) was dereferenced by every per-file `existsSync`/ + // `stagedCopy` read below `filesDir`, copying victim-readable content into + // an attacker-named path inside `configDir`. + test('E10: recovery refuses an entryDir whose `files` child is a symlink — never dereferences the source side', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-cfg-')); + const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-victim-')); + try { + fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT'); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'e10entry0000000'); + fs.mkdirSync(entryDir, { recursive: true }); + fs.symlinkSync(victim, path.join(entryDir, 'files')); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'must refuse — the files/ child is a symlink to an untrusted directory'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'files-symlink-escape'); + assert.ok( + !fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')), + 'the victim directory\'s content must never be copied into destDir', + ); + } finally { + cleanup(configDir); + cleanup(victim); + } + }); + + // #2875 defect fix (security/correctness — cwd-dependent confinement): a + // RELATIVE `record.destDir` made `path.relative(configHome, record.destDir)` + // resolve the relative argument against `process.cwd()` internally, not + // against `configHome` — so recovery's behavior for a forged or malformed + // record depended on whatever directory the CLI happened to be invoked + // from, rather than being a deterministic function of `configDir`. + // `stageUserArtifacts` (this module's own writer) always records an + // ALREADY-resolved absolute `destDir`; a relative one only ever reaches + // recovery via a forged/hand-edited record. + test('E11: recovery refuses a RELATIVE record.destDir — confinement never depends on process.cwd()', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e11-cfg-')); + t.after(() => cleanup(configDir)); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'e11entry0000000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'x.md'), 'x'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: 'gsd-core', names: ['x.md'], timestamp: new Date().toISOString() }), + ); + + // Run from a cwd that resolves the relative destDir straight back to + // configDir (mirrors the real cline-local call shape, where targetDir + // === process.cwd()) — the exact case that must NOT be treated as + // "correctly resolved" just because the coincidence lines up. + const previousCwd = process.cwd(); + t.after(() => process.chdir(previousCwd)); + process.chdir(configDir); + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'a relative destDir must never be accepted, regardless of cwd'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'destDir-outside-confinement'); + }); + + // #2875 security-review finding: E10 covers the default (no opt-in) case. + // `GSD_ALLOW_SYMLINKED_DEST` is documented (install-engine.cts + // `isSymlinkedDestOptIn`) as relaxing only the DESTINATION-side + // pre-existing-symlink refusal — the user asserting they own/trust a + // symlinked WRITE destination. `entryDir`/`filesDir` here is the staging + // SOURCE, GSD-owned internal state this module creates itself, never a + // user-authored layout — the opt-in must NOT relax this read-side check. + test('E12: recovery refuses the source-side `files/` symlink even with GSD_ALLOW_SYMLINKED_DEST=1 — the dest opt-in never relaxes the source-side read', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-cfg-')); + const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-victim-')); + t.after(() => { + cleanup(configDir); + cleanup(victim); + }); + fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT'); + const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts'); + const entryDir = path.join(stagingRoot, 'e12entry0000000'); + fs.mkdirSync(entryDir, { recursive: true }); + try { + fs.symlinkSync(victim, path.join(entryDir, 'files')); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }), + ); + + process.env.GSD_ALLOW_SYMLINKED_DEST = '1'; + t.after(() => delete process.env.GSD_ALLOW_SYMLINKED_DEST); + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + + assert.equal(result.recovered.length, 0, 'must refuse — the source-side files/ symlink is never relaxed by the dest opt-in'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'files-symlink-escape'); + assert.ok( + !fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')), + 'the victim directory\'s content must never be copied into destDir, even with the opt-in set', + ); + }); + + // ------------------------------------------------------------------------- + // Parity: install-engine.cts's `_resolveUserArtifactStagingRoot` is + // deliberately duplicated (not shared) into bin/install.js, to avoid a + // circular `require` between the two (see that function's own doc comment + // in both files). This codebase names unguarded duplication across + // parallel surfaces "Generative Fix Divergence" and requires a parity + // assertion that fails the moment the two copies diverge — same inputs, + // same resolved root, same refusal behavior. + // ------------------------------------------------------------------------- + + test('parity: install-engine.cts and bin/install.js copies of _resolveUserArtifactStagingRoot resolve the SAME root for the same configDir', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-happy-')); + try { + const fromEngine = _resolveUserArtifactStagingRoot(configDir); + const fromInstallJs = _installJsResolveUserArtifactStagingRoot(configDir); + assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root'); + } finally { + cleanup(configDir); + } + }); + + test('parity: both copies refuse a symlinked staging root identically', () => { + const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-engine-')); + const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-installjs-')); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-elsewhere-')); + try { + fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging')); + fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging')); + + let engineThrew = false; + let engineMessage = ''; + try { + _resolveUserArtifactStagingRoot(configDirEngine); + } catch (err) { + engineThrew = true; + engineMessage = err.message; + } + let installJsThrew = false; + let installJsMessage = ''; + try { + _installJsResolveUserArtifactStagingRoot(configDirInstallJs); + } catch (err) { + installJsThrew = true; + installJsMessage = err.message; + } + + assert.ok(engineThrew, 'install-engine.cts copy must refuse a symlinked staging root'); + assert.ok(installJsThrew, 'bin/install.js copy must refuse a symlinked staging root'); + // Both messages must carry the same refusal shape (symlink refusal), + // even though configDir differs between the two (each needs its own + // sandbox — the throw itself, not the literal path, is what parity + // checks here). + assert.match(engineMessage, /symlink/i); + assert.match(installJsMessage, /symlink/i); + } finally { + cleanup(configDirEngine); + cleanup(configDirInstallJs); + cleanup(elsewhere); + } + }); + + // ------------------------------------------------------------------------- + // Parity: the DEGRADE-not-abort wrapper (`_tryResolveUserArtifactStagingRoot`) + // is ALSO duplicated verbatim into bin/install.js (same doc-comment + // rationale as the throwing version above). "Same inputs, same resolved + // root, same refusal" above does not cover "same degrade": a caller-facing + // regression where one copy started throwing again (bricking the command) + // while the other correctly degraded to `null` would slip past the tests + // above, since neither calls the wrapper. + // ------------------------------------------------------------------------- + + test('parity: install-engine.cts and bin/install.js copies of _tryResolveUserArtifactStagingRoot resolve the SAME root for the same configDir (happy path)', (t) => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-happy-')); + t.after(() => cleanup(configDir)); + const fromEngine = _tryResolveUserArtifactStagingRoot(configDir); + const fromInstallJs = _installJsTryResolveUserArtifactStagingRoot(configDir); + assert.notEqual(fromEngine, null, 'the engine copy must resolve (not degrade) on a clean configDir'); + assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root'); + }); + + test('parity: both copies of _tryResolveUserArtifactStagingRoot degrade to null (never throw) for the SAME symlinked staging root', (t) => { + const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-engine-')); + const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-installjs-')); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-elsewhere-')); + t.after(() => { + cleanup(configDirEngine); + cleanup(configDirInstallJs); + cleanup(elsewhere); + }); + fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging')); + fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging')); + + let engineThrew = false; + let engineResult; + try { + engineResult = _tryResolveUserArtifactStagingRoot(configDirEngine); + } catch { + engineThrew = true; + } + let installJsThrew = false; + let installJsResult; + try { + installJsResult = _installJsTryResolveUserArtifactStagingRoot(configDirInstallJs); + } catch { + installJsThrew = true; + } + + assert.equal(engineThrew, false, 'the engine copy\'s try-wrapper must never throw — this is the whole point of the wrapper'); + assert.equal(installJsThrew, false, 'the bin/install.js copy\'s try-wrapper must never throw either'); + assert.equal(engineResult, null, 'the engine copy must degrade to null for a refused staging root'); + assert.equal(installJsResult, null, 'the bin/install.js copy must degrade to null identically'); + }); +}); + +// --------------------------------------------------------------------------- +// migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write (security +// review finding, found while closing the #2875 agents-descriptor migration +// gap): existsSync(skillFile) follows symlinks and reports false for a +// dangling one, and the symlink-escape guard only walked to the PARENT +// directory, never lstat-checking the leaf file itself — so a dangling +// symlink planted exactly at the skill-file destination sailed through both +// checks and writeFileSync (which DOES follow symlinks) wrote attacker +// content to the symlink's target. +// --------------------------------------------------------------------------- + +describe('migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write', () => { + const { migrateLegacyDevPreferencesToSkill } = require('../gsd-core/bin/lib/install-engine.cjs'); + + test('refuses to write through a dangling symlink planted at the skill-file leaf', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-outside-')); + const attackerTarget = path.join(outside, 'authorized_keys'); + try { + const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences'); + fs.mkdirSync(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + fs.symlinkSync(attackerTarget, skillFile); // dangling — target does not exist + + const saved = new Map([['dev-preferences.md', 'attacker-controlled content\n']]); + + assert.throws( + () => migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global'), + /symlink/i, + 'must refuse to write through a symlinked skill-file leaf', + ); + assert.ok(!fs.existsSync(attackerTarget), 'attacker target must never be created/written'); + } finally { + cleanup(configDir); + cleanup(outside); + } + }); + + test('a real, already-migrated skill file (not a symlink) still short-circuits as before', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-real-')); + try { + const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences'); + fs.mkdirSync(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(skillFile, 'already migrated\n', 'utf8'); + + const saved = new Map([['dev-preferences.md', 'new content\n']]); + const migrated = migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global'); + + assert.strictEqual(migrated, false, 'must not clobber an existing real skill file'); + assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), 'already migrated\n'); + } finally { + cleanup(configDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// uninstallRuntimeArtifacts — staged legacy dev-preferences.md symlink +// dereference (security review finding). Sibling call sites +// (_runLegacyInstallMigrations, bin/install.js's own uninstall()) already +// lstat-guard a staged name before readFileSync; this uninstall call site did +// not, so a symlinked staged dev-preferences.md had its referent's bytes read +// into SKILL.md, and a symlink to a directory threw EISDIR uncaught. +// --------------------------------------------------------------------------- + +describe('uninstallRuntimeArtifacts — staged dev-preferences.md symlink dereference', () => { + const { uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); + + test('a symlinked staged dev-preferences.md is skipped (never dereferenced) and uninstall does not throw', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-')); + const secretFile = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-secret-')); + const secretPath = path.join(secretFile, 'id_rsa'); + fs.writeFileSync(secretPath, 'PRIVATE KEY MATERIAL\n', 'utf8'); + try { + // Claude global uses the legacy commands/gsd/ location. + const legacyDir = path.join(configDir, 'commands', 'gsd'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.symlinkSync(secretPath, path.join(legacyDir, 'dev-preferences.md')); + + // Must not throw (no EISDIR/unhandled error) and must never migrate the + // symlink's referent content into any installed skill file. + assert.doesNotThrow(() => uninstallRuntimeArtifacts('claude', configDir, 'global')); + + const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md'); + if (fs.existsSync(skillFile)) { + const content = fs.readFileSync(skillFile, 'utf8'); + assert.ok(!content.includes('PRIVATE KEY MATERIAL'), 'the symlink referent must never be migrated into SKILL.md'); + } + } finally { + cleanup(configDir); + cleanup(secretFile); + } + }); +}); + +// --------------------------------------------------------------------------- +// #2875 defect fix (security/regression): a staging-root resolution failure +// (e.g. `.gsd-staging/user-artifacts` is/contains a symlink) must DEGRADE — +// skip staging for that step, warn — rather than abort install()/uninstall() +// entirely. Before this fix, `_resolveUserArtifactStagingRoot` was called +// UNGUARDED as the first statement of both, so a hostile/broken +// `.gsd-staging` path bricked BOTH commands, including uninstall — the +// remedy for the first problem. +// --------------------------------------------------------------------------- + +describe('install()/uninstall() degrade (never abort) on a staging-root resolution failure', () => { + function withHostileStagingSymlink(runFn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-')); + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-elsewhere-')); + const previousCwd = process.cwd(); + process.chdir(tmp); + try { + fs.mkdirSync(path.join(tmp, '.gsd-staging')); + fs.symlinkSync(elsewhere, path.join(tmp, '.gsd-staging', 'user-artifacts')); + runFn(tmp); + } finally { + process.chdir(previousCwd); + cleanup(tmp); + cleanup(elsewhere); + } + } + + test('install() proceeds and still writes gsd-core/ when the staging root is a hostile symlink', () => { + withHostileStagingSymlink((tmp) => { + assert.doesNotThrow(() => install(false, 'cline'), 'install() must degrade, never throw, on a staging-root failure'); + assert.ok(fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be installed despite the staging-root failure'); + }); + }); + + test('uninstall() proceeds and still removes gsd-core/ when the staging root is a hostile symlink', () => { + withHostileStagingSymlink((tmp) => { + // Bypass the (also-degrading) install() path to set up a realistic + // pre-existing gsd-core/ without depending on install() itself. + fs.mkdirSync(path.join(tmp, 'gsd-core'), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'gsd-core', 'USER-PROFILE.md'), 'user content'); + assert.doesNotThrow(() => uninstall(false, 'cline'), 'uninstall() must degrade, never throw, on a staging-root failure'); + assert.ok(!fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be removed despite the staging-root failure'); + }); + }); +}); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 11811415d..c164c623b 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -3558,16 +3558,17 @@ describe('uninstall — manifest cleanup (#1908)', () => { * Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest * * USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user. - * preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest() - * also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was - * on disk at install time. On the next install, saveLocalPatches() compares the on-disk - * (refreshed) hash to the manifest hash, finds them different, and emits the spurious - * "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning. + * The user-artifact-staging.cts durable staging path (#2875) correctly preserves it + * across reinstalls. But writeManifest() also records it under + * "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was on disk at install time. + * On the next install, saveLocalPatches() compares the on-disk (refreshed) hash to + * the manifest hash, finds them different, and emits the spurious "Found N locally + * modified GSD file(s) — backed up to gsd-local-patches/" warning. * * Invariant: a file is either distribution (manifest-tracked, diff'd against manifest) * or user artifact (preserved across installs, never diff'd). It cannot be both. The * shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both - * preserveUserArtifacts callers and writeManifest. + * the staging call sites and writeManifest. * * Closes: #2771 */ @@ -3639,7 +3640,7 @@ describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => }); }); -// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ──────────── +// ─── Test 2: USER-PROFILE.md is still preserved (via durable staging, #2875) ── describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => { let tmpDir; diff --git a/tests/model-resolver.test.cjs b/tests/model-resolver.test.cjs index dde922712..8833eeeb4 100644 --- a/tests/model-resolver.test.cjs +++ b/tests/model-resolver.test.cjs @@ -5609,6 +5609,75 @@ describe('issue #2517: install end-to-end — per-project config reaches Codex T }); }); +// ─── #2875: install-model-override-resolver.cts's `depth < 8` upward-walk ── +// boundary (CLAUDE.md boundary coverage: a budget limit must be exercised at +// limit-1/limit/limit+1). The walk starts AT targetDir (checked at depth=0, +// "0 levels up") and stops after depth=7 ("7 levels up", the LAST reachable +// ancestor) — a `.planning/config.json` 8 levels up is never reached. Both +// `readGsdRuntimeProfileResolver` and `readGsdEffectiveModelOverrides` run +// the identical loop shape; readGsdRuntimeProfileResolver is exercised here +// since resolver.runtime !== null is a simple, direct found/not-found signal. +describe('#2875: install-model-override-resolver upward-walk depth boundary (limit-1/limit/limit+1)', () => { + const { readGsdRuntimeProfileResolver } = require('../gsd-core/bin/lib/install-model-override-resolver.cjs'); + + beforeEach(() => { isolateHome(); resetRuntimeWarningCaches(); }); + afterEach(() => { restoreHome(); }); + + // Builds an 8-level-deep directory chain under a fresh temp root and + // returns { root, leaf }, where leaf is 8 levels below root (root/L1/../L8). + // ancestorLevelsUp(leaf, n) === root/L1/../L(8-n). + function buildDeepChain() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-depth-walk-')); + let dir = root; + for (let i = 1; i <= 8; i += 1) { + dir = path.join(dir, `L${i}`); + } + fs.mkdirSync(dir, { recursive: true }); + return { root, leaf: dir }; + } + + function ancestorLevelsUp(leaf, n) { + let dir = leaf; + for (let i = 0; i < n; i += 1) dir = path.dirname(dir); + return dir; + } + + // writeConfig assumes `/.planning/` already exists (every other call + // site in this file writes into a `createTempProject()`-scaffolded tree, + // which pre-creates it) — the bare ancestor dirs `buildDeepChain` makes do + // not, so create it first. + function writeConfigAt(dir, obj) { + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + writeConfig(dir, obj); + } + + test('limit-1: config.json 6 levels up from targetDir is found', (t) => { + const { root, leaf } = buildDeepChain(); + t.after(() => cleanup(root)); + writeConfigAt(ancestorLevelsUp(leaf, 6), { runtime: 'codex', model_profile: 'quality' }); + const resolver = readGsdRuntimeProfileResolver(leaf); + assert.ok(resolver, 'a config.json 6 levels up must be found — well within the 8-deep walk'); + assert.strictEqual(resolver.runtime, 'codex'); + }); + + test('limit: config.json 7 levels up from targetDir is found — the LAST reachable ancestor', (t) => { + const { root, leaf } = buildDeepChain(); + t.after(() => cleanup(root)); + writeConfigAt(ancestorLevelsUp(leaf, 7), { runtime: 'codex', model_profile: 'quality' }); + const resolver = readGsdRuntimeProfileResolver(leaf); + assert.ok(resolver, 'a config.json exactly 7 levels up (the walk\'s last checked ancestor) must still be found'); + assert.strictEqual(resolver.runtime, 'codex'); + }); + + test('limit+1: config.json 8 levels up from targetDir is NEVER found — one level past what the walk reaches', (t) => { + const { root, leaf } = buildDeepChain(); + t.after(() => cleanup(root)); + writeConfigAt(ancestorLevelsUp(leaf, 8), { runtime: 'codex', model_profile: 'quality' }); + const resolver = readGsdRuntimeProfileResolver(leaf); + assert.strictEqual(resolver, null, 'a config.json 8 levels up is past the walk\'s cap and must not be found'); + }); +}); + // ─── RUNTIME_PROFILE_MAP single source of truth (finding #16) ─────────────── describe('issue #2517: RUNTIME_PROFILE_MAP single source of truth (finding #16)', () => { test('install.js consumes the same map as model-catalog.cjs', () => { diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index 27a8b1bad..3a14f340d 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -49,8 +49,16 @@ const FAKE_DIR = '/tmp/fake-config-dir-dd'; const GOLDEN = { // ── claude ────────────────────────────────────────────────────────────────── + // #2875 Part 2 (the agents-descriptor migration): claude/global gains an + // `agents` kind. This is NOT new on-disk behavior — a `claude --global` + // install always wrote `/agents/gsd-*.md` via the now-deleted + // inline agent-staging loop in bin/install.js (claude was never in + // `_DESCRIPTOR_AGENTS_RUNTIMES`, and that loop ran unconditionally, + // independent of `_isSkillsRuntime`/scope). The descriptor previously never + // modeled that write; it now does, matching what was always materialized. 'claude/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'claude/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, // #1367: flat gsd-.md @@ -73,11 +81,17 @@ const GOLDEN = { // ── codex ──────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // #2875 Part 2: agents kind added (the inline loop wrote codex agents too — + // codex was never in `_DESCRIPTOR_AGENTS_RUNTIMES`; the config.toml + // [agents.gsd-*] strip on a full→minimal downgrade is a SEPARATE, still + // hand-rolled write this migration deliberately does not touch). 'codex/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'codex/local': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── copilot ────────────────────────────────────────────────────────────────── @@ -155,11 +169,15 @@ const GOLDEN = { // ── hermes ─────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // #2875 Part 2: agents kind added (the inline loop wrote hermes agents too, + // via a new named branding converter — convertClaudeAgentToHermesAgent). 'hermes/global': [ { kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'hermes/local': [ { kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── codebuddy ──────────────────────────────────────────────────────────────── @@ -178,10 +196,26 @@ const GOLDEN = { // ── cline ──────────────────────────────────────────────────────────────────── // Old switch: scope='global' → [skills]; scope='local' → []. Matches descriptor. + // #2875 Part 2: agents kind added to global (the inline loop wrote cline + // agents too, via convertClaudeAgentToClineAgent). + // #2875 Part 2 defect fix (cline-local agents-drop regression, closed): + // cline was never scope-gated in the deleted inline loop either, so a real + // `cline --local` install ALSO wrote agent files pre-migration (to the + // project root, per hostBehaviors.localTargetIsProjectRoot). `local` now + // ALSO declares the agents kind (capabilities/cline/capability.json), + // restoring that behavior — see tests/agent-descriptor-parity.test.cjs's + // cline (local) H row and tests/cline-install.test.cjs's local-install + // regression test for the byte-parity / end-to-end proofs. `local` still + // declares no `skills` kind: cline-local commands are embedded in + // .clinerules, never materialized as skill files + // (hostBehaviors.localCommandsViaRules). 'cline/global': [ { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, + ], + 'cline/local': [ + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], - 'cline/local': [], // ── kimi ───────────────────────────────────────────────────────────────────── // Old switch: scope='global' → [skills, kimi-agents]; scope='local' → []. Matches descriptor. @@ -197,24 +231,35 @@ const GOLDEN = { // OpenCode discovers slash commands from commands/ (plural); the singular // command/ dir GSD previously wrote to is not scanned by OpenCode 1.17.13, // so none of the ~71 /gsd-* commands ever appeared in the OpenCode TUI. + // #2875 Part 2: agents kind added (the inline loop wrote opencode agents + // too — opencode's agents materialization previously went through the + // separate installOpencodeFamilySkills-adjacent inline loop, not through + // this layout at all; installAgentsKindStandalone now covers it). 'opencode/global': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'opencode/local': [ { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── kilo ───────────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // #2875 Part 2: agents kind added (mirrors opencode — same combined-family + // install shape, same install-engine.cts installOpencodeFamilyArtifacts / + // installAgentsKindStandalone coverage). 'kilo/global': [ { kind: 'commands', destSubpath: 'command', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], 'kilo/local': [ { kind: 'commands', destSubpath: 'command', prefix: 'gsd-' }, { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], }; diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 20c79c1ee..e95a8f0ce 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -52,15 +52,29 @@ describe('resolveRuntimeArtifactLayout — claude local', () => { }); describe('resolveRuntimeArtifactLayout — claude global', () => { + // #2875 Part 2: claude/global gained an `agents` kind — NOT new on-disk + // behavior. A `claude --global` install always wrote + // `/agents/gsd-*.md` via the now-deleted inline agent-staging + // loop in bin/install.js (claude was never in the deleted + // `_DESCRIPTOR_AGENTS_RUNTIMES` set, and that loop ran unconditionally, + // independent of `_isSkillsRuntime`/scope). The descriptor previously never + // modeled that write; it now does, matching what was always materialized — + // which is also why the golden install-tree fixtures never moved (see + // tests/fixtures/install-tree/**): the on-disk bytes were unchanged, only + // the descriptor's own metadata became complete. test('returns correct layout for claude scope=global', () => { const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); assert.strictEqual(layout.runtime, 'claude'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'skills'); assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); }); @@ -89,15 +103,23 @@ describe('resolveRuntimeArtifactLayout — cursor', () => { }); describe('resolveRuntimeArtifactLayout — codex', () => { + // #2875 Part 2: agents kind added — codex was never in the deleted + // `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote codex agents + // too; the descriptor now models it. Codex's separate config.toml + // `[agents.gsd-*]` strip on a full→minimal downgrade is untouched. test('returns correct layout for codex', () => { const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR); assert.strictEqual(layout.runtime, 'codex'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'skills'); assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); test('#2429: codex local scope does not set $HOME/.agents skills home override', () => { @@ -294,15 +316,23 @@ describe('resolveRuntimeArtifactLayout — kimi', () => { }); describe('resolveRuntimeArtifactLayout — hermes', () => { + // #2875 Part 2: agents kind added — hermes was never in the deleted + // `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote hermes agents + // too (brand-swapped via the new named converter convertClaudeAgentToHermesAgent, + // whose rewrite data was already declared on hostBehaviors.brandingRewrites). test('returns correct layout for hermes', () => { const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR); assert.strictEqual(layout.runtime, 'hermes'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'skills'); assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); // #947: restored canonical prefix assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); }); @@ -334,31 +364,44 @@ describe('resolveRuntimeArtifactLayout — codebuddy', () => { }); describe('resolveRuntimeArtifactLayout — cline', () => { + // #2875 Part 2: agents kind added to cline/global — cline was never in the + // deleted `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote cline + // agents too, via convertClaudeAgentToClineAgent. test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => { const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'global'); assert.strictEqual(layout.runtime, 'cline'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'skills'); assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); - test('cline local: no skills kinds (global-only, #782)', () => { + test('cline local: no skills kind (skills are global-only, #782); agents kind present (#2875 Part 2 defect fix, cline-local agents-drop regression)', () => { const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'local'); assert.strictEqual(layout.runtime, 'cline'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 0); + const kindNames = layout.kinds.map((k) => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['agents'], 'cline local must declare only the agents kind — no skills kind'); }); }); describe('resolveRuntimeArtifactLayout — opencode', () => { - test('returns commands + skills layout for opencode (#784)', () => { + // #2875 Part 2: agents kind added — opencode/kilo's agents were previously + // written by a code path entirely separate from this layout + // (installOpencodeFamilyArtifacts's bespoke writers never called + // resolveRuntimeArtifactLayout for agents); installAgentsKindStandalone now + // covers them through the SAME descriptor this layout resolves. + test('returns commands + skills + agents layout for opencode (#784)', () => { const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); assert.strictEqual(layout.runtime, 'opencode'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds.length, 3); const commands = layout.kinds.find((k) => k.kind === 'commands'); assert.ok(commands, 'should have a commands kind'); @@ -373,15 +416,23 @@ describe('resolveRuntimeArtifactLayout — opencode', () => { assert.strictEqual(skills.destSubpath, 'skills'); assert.strictEqual(skills.prefix, 'gsd-'); assert.strictEqual(typeof skills.stage, 'function'); + + const agents = layout.kinds.find((k) => k.kind === 'agents'); + assert.ok(agents, 'should have an agents kind'); + assert.strictEqual(agents.destSubpath, 'agents'); + assert.strictEqual(agents.prefix, 'gsd-'); + assert.strictEqual(typeof agents.stage, 'function'); }); }); describe('resolveRuntimeArtifactLayout — kilo', () => { - test('returns commands + skills layout for kilo (#784)', () => { + // #2875 Part 2: agents kind added — same reason as opencode above (kilo + // shares the combined-family install shape). + test('returns commands + skills + agents layout for kilo (#784)', () => { const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR); assert.strictEqual(layout.runtime, 'kilo'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds.length, 3); const commands = layout.kinds.find((k) => k.kind === 'commands'); assert.ok(commands, 'should have a commands kind'); @@ -394,6 +445,12 @@ describe('resolveRuntimeArtifactLayout — kilo', () => { assert.strictEqual(skills.destSubpath, 'skills'); assert.strictEqual(skills.prefix, 'gsd-'); assert.strictEqual(typeof skills.stage, 'function'); + + const agents = layout.kinds.find((k) => k.kind === 'agents'); + assert.ok(agents, 'should have an agents kind'); + assert.strictEqual(agents.destSubpath, 'agents'); + assert.strictEqual(agents.prefix, 'gsd-'); + assert.strictEqual(typeof agents.stage, 'function'); }); }); @@ -420,10 +477,14 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => { assert.ok(!kindNames.includes('commands'), 'cursor commands kind would duplicate skill menu entries'); }); - test('claude global has only skills kind', () => { + // #2875 Part 2: claude/global now also declares an `agents` kind (see the + // 'resolveRuntimeArtifactLayout — claude global' describe block above for + // the full "was this new on-disk behavior?" determination — it is not). + test('claude global has skills and agents kinds', () => { const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); - assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); }); test('unknown runtime grok throws TypeError containing runtime name', () => { diff --git a/tests/user-artifact-staging.test.cjs b/tests/user-artifact-staging.test.cjs new file mode 100644 index 000000000..9ee6b9766 --- /dev/null +++ b/tests/user-artifact-staging.test.cjs @@ -0,0 +1,985 @@ +'use strict'; + +/** + * User Artifact Staging Module — the module's own contract (#2875, epic + * #2866 Phase 6, governed by ADR-3574 and + * .gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md). + * + * Confinement rows (E1-E5) live in tests/install-write-confinement.test.cjs + * (this suite's own file-count ratchet keeps them there — see 50-test-matrix.md + * "Suites"). Call-site integration (Hermes/Claude dev-preferences migration, + * the mainline gsd-core copy, and C7 production-reachability) lives in + * tests/install-runtime-artifacts.test.cjs. + * + * Crash-window injection is by monkeypatching a real `node:fs` method via + * `t.mock.method` (auto-restoring, or explicitly `.mock.restore()`d where a + * later `t.after` teardown needs the real method back before the mock + * tracker's own end-of-test restore runs) — never chmod/permission tricks + * (root bypasses mode bits in CI), and never a manual try/finally + * (CONTRIBUTING.md bans try/finally in test bodies). This works because + * install-fs-adapter.cts's REAL_ADAPTER calls `nodeFs.(...)` as a + * live property lookup on the `node:fs` module object at call time, not a + * captured reference (see that module's own doc comment). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const fc = require('fast-check'); + +const { cleanup } = require('./helpers.cjs'); + +const { + stageUserArtifacts, + restoreStagedUserArtifacts, + discardStagedUserArtifacts, + recoverOrphanedUserArtifacts, + parseOwnerPid, +} = require('../gsd-core/bin/lib/user-artifact-staging.cjs'); +const { withInstallFs } = require('../gsd-core/bin/lib/install-fs-adapter.cjs'); +const { copyPreservingSymlink } = require('../gsd-core/bin/lib/installer-migrations.cjs'); + +function mktemp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function stagingRootFor(configDir) { + return path.join(configDir, '.gsd-staging', 'user-artifacts'); +} + +// --------------------------------------------------------------------------- +// A. Staging contract +// --------------------------------------------------------------------------- + +describe('user-artifact-staging: A. staging contract', () => { + test('A1: one existing file stages before any wipe; record written after', (t) => { + const destDir = mktemp('gsd-uas-a1-dest-'); + const configDir = mktemp('gsd-uas-a1-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'hello world\n', 'utf8'); + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir)); + + assert.ok(fs.existsSync(path.join(staged.filesDir, 'USER-PROFILE.md')), 'copy landed in staging'); + assert.ok(fs.existsSync(staged.recordPath), 'record.json exists'); + const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8')); + assert.deepEqual(record.names, ['USER-PROFILE.md']); + assert.equal(record.destDir, path.resolve(destDir)); + }); + + test('A2: file absent from destDir — not staged, no record entry, no throw', (t) => { + const destDir = mktemp('gsd-uas-a2-dest-'); + const configDir = mktemp('gsd-uas-a2-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + const staged = stageUserArtifacts(destDir, ['does-not-exist.md'], stagingRootFor(configDir)); + assert.deepEqual(staged.names, []); + assert.ok(!fs.existsSync(path.join(staged.filesDir, 'does-not-exist.md'))); + }); + + test('A3: fileNames empty — empty staging, record still coherent', (t) => { + const destDir = mktemp('gsd-uas-a3-dest-'); + const configDir = mktemp('gsd-uas-a3-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + const staged = stageUserArtifacts(destDir, [], stagingRootFor(configDir)); + assert.deepEqual(staged.names, []); + assert.ok(fs.existsSync(staged.recordPath), 'record still written for an empty batch'); + const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8')); + assert.deepEqual(record.names, []); + }); + + test('A4: symlinked file — the link is recreated, the referent is never read', (t) => { + const destDir = mktemp('gsd-uas-a4-dest-'); + const configDir = mktemp('gsd-uas-a4-cfg-'); + const targetDir = mktemp('gsd-uas-a4-target-'); + t.after(() => { cleanup(destDir); cleanup(configDir); cleanup(targetDir); }); + + const targetFile = path.join(targetDir, 'secret.txt'); + fs.writeFileSync(targetFile, 'referent-bytes-must-never-be-read'); + fs.symlinkSync(targetFile, path.join(destDir, 'USER-PROFILE.md')); + + const originalReadFileSync = fs.readFileSync; + let readFileSyncCalledOnTarget = false; + // t.mock.method auto-restores the original after this test — no manual + // try/finally (CONTRIBUTING.md bans try/finally in test bodies). + t.mock.method(fs, 'readFileSync', (p, ...rest) => { + if (String(p) === targetFile) readFileSyncCalledOnTarget = true; + return originalReadFileSync.call(fs, p, ...rest); + }); + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir)); + + assert.equal(readFileSyncCalledOnTarget, false, 'referent bytes must never be read'); + const stagedPath = path.join(staged.filesDir, 'USER-PROFILE.md'); + assert.ok(fs.lstatSync(stagedPath).isSymbolicLink(), 'staged copy is itself a symlink'); + assert.equal(fs.readlinkSync(stagedPath), targetFile); + }); + + test('A5: staged copy is byte-identical, including CRLF and a trailing newline', (t) => { + const destDir = mktemp('gsd-uas-a5-dest-'); + const configDir = mktemp('gsd-uas-a5-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + const content = 'line one\r\nline two\r\nno-trailing-newline'; + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), content); + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir)); + const stagedContent = fs.readFileSync(path.join(staged.filesDir, 'USER-PROFILE.md'), 'utf8'); + assert.equal(stagedContent, content); + }); + + test('A6: staging the same destDir twice does not corrupt the first record, and clears stale files from the prior batch', (t) => { + const destDir = mktemp('gsd-uas-a6-dest-'); + const configDir = mktemp('gsd-uas-a6-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'v1'); + fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'stale-from-first-batch'); + const first = stageUserArtifacts(destDir, ['USER-PROFILE.md', 'dev-preferences.md'], stagingRootFor(configDir)); + assert.deepEqual(first.names.sort(), ['USER-PROFILE.md', 'dev-preferences.md']); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'v2'); + // Second call only asks for USER-PROFILE.md — dev-preferences.md is no + // longer part of this batch. Deterministic key reuse is asserted + // directly against `first`, captured BEFORE this call — #2875 defect + // fix: previously this equality check was performed by folding a SECOND + // `stageUserArtifacts` call directly into the assertion, which + // overwrote `first`'s own on-disk state (record.json + files/) before it + // was ever re-examined, so the test's own "does not corrupt the first + // record" claim was never actually checked. + const second = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir)); + assert.equal(second.entryDir, first.entryDir, 'same destDir maps to the same staging entry (deterministic key)'); + + const content = fs.readFileSync(path.join(second.filesDir, 'USER-PROFILE.md'), 'utf8'); + assert.equal(content, 'v2', 'second stage call overwrites the first cleanly, no corruption'); + assert.ok( + !fs.existsSync(path.join(second.filesDir, 'dev-preferences.md')), + 'the entry dir is cleared FIRST — a file from a prior batch must not linger alongside the new one', + ); + + // #2875 defect fix: re-examine `first`'s OWN captured paths (identical to + // `second`'s, by construction — same destDir, same deterministic key) + // AFTER the second stage call, proving the record was cleanly REPLACED, + // not merged or corrupted. A bug that failed to clear the entry dir + // before rewriting record.json (e.g. names from both batches surviving) + // would fail this and did not fail the old assertions. + const recordAfterSecondStage = JSON.parse(fs.readFileSync(first.recordPath, 'utf8')); + assert.deepEqual( + recordAfterSecondStage.names, ['USER-PROFILE.md'], + 'the record at the path `first` originally wrote must now reflect ONLY the second batch', + ); + assert.ok( + !fs.existsSync(path.join(first.filesDir, 'dev-preferences.md')), + 'dev-preferences.md staged by the FIRST batch must be gone from the (shared) files dir after the second call', + ); + }); + + test('A7: crash between copy and record — treated as incomplete, not a recovery source', (t) => { + const destDir = mktemp('gsd-uas-a7-dest-'); + const configDir = mktemp('gsd-uas-a7-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'content'); + const stagingRoot = stagingRootFor(configDir); + + const originalWriteFileSync = fs.writeFileSync; + // t.mock.method auto-restores the original after this test — no manual + // try/finally (CONTRIBUTING.md bans try/finally in test bodies). + t.mock.method(fs, 'writeFileSync', (p, ...rest) => { + if (String(p).endsWith('record.json')) { + throw Object.assign(new Error('simulated crash before record commit'), { code: 'EIO' }); + } + return originalWriteFileSync.call(fs, p, ...rest); + }); + assert.throws(() => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot), /simulated crash/); + + // The copy landed (files/ populated) but no record.json — recovery must + // ignore this half-staged entry entirely (B4). + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result.recovered, [], 'a recordless staging entry is never a recovery source'); + }); + + test('A8: the record timestamp comes from the injected clock seam, never the real wall clock', (t) => { + const destDir = mktemp('gsd-uas-a8-dest-'); + const configDir = mktemp('gsd-uas-a8-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'x'); + const pinnedMs = Date.parse('2001-01-01T00:00:00.000Z'); + const fakeClock = { now: () => pinnedMs }; + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir), { clock: fakeClock }); + const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8')); + assert.equal(record.timestamp, new Date(pinnedMs).toISOString()); + }); + + test('A9: restoreStagedUserArtifacts opts.rename restores a staged name under a DIFFERENT destination file name', (t) => { + const destDir = mktemp('gsd-uas-a9-dest-'); + const configDir = mktemp('gsd-uas-a9-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + + fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'legacy content'); + const staged = stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRootFor(configDir)); + cleanup(destDir); + fs.mkdirSync(destDir, { recursive: true }); + + restoreStagedUserArtifacts(destDir, staged, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } }); + + assert.ok(!fs.existsSync(path.join(destDir, 'dev-preferences.md')), 'the OLD name must not be recreated'); + assert.equal( + fs.readFileSync(path.join(destDir, 'gsd-dev-preferences.md'), 'utf8'), + 'legacy content', + 'content lands under the renamed destination', + ); + }); + + test('A10 (#2875 F1 concurrency defect): two concurrent RUNS staging the SAME destDir do not clobber each other', (t) => { + // Reproduces the actual race: the staging key was `sha256(destDir)` + // alone, so a SECOND run's stageUserArtifacts call — its entryDir- + // clearing rmSync — destroyed a FIRST, already-committed run's batch for + // the same destDir. `runId` (default `process.pid`, here overridden to + // simulate two DIFFERENT concurrent processes without forking a real + // one) discriminates the two runs' staging keys. Passing `runId` against + // the PRE-FIX module is a silent no-op (the option did not exist; the + // key ignored it and both calls still collided on sha256(destDir) alone) + // — so this exact test body is the red-then-green proof: FAILS against + // the code before the fix, PASSES after. + const destDir = mktemp('gsd-uas-a10-dest-'); + const configDir = mktemp('gsd-uas-a10-cfg-'); + t.after(() => { cleanup(destDir); cleanup(configDir); }); + const stagingRoot = stagingRootFor(configDir); + + // Run A stages and commits first. + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'run-A content'); + const runA = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: 'process-A-pid-100' }); + assert.ok(fs.existsSync(runA.recordPath), 'run A committed its record'); + assert.equal(fs.readFileSync(path.join(runA.filesDir, 'USER-PROFILE.md'), 'utf8'), 'run-A content'); + + // Run B — a SEPARATE concurrent run (different runId) racing the SAME + // destDir, starting its own stage call before run A has restored or + // discarded its batch. This is the exact interleaving the F1 race + // describes: two processes' preserve/wipe/restore cycles overlapping. + fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'run-B content'); + const runB = stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRoot, { runId: 'process-B-pid-200' }); + + // Run A's committed batch must survive run B's entryDir-clearing step — + // under the pre-fix shared key, run B's initial rmSync(entryDir) wiped + // run A's record.json and files/ before this assertion ever runs. + assert.ok(fs.existsSync(runA.recordPath), 'run A record must survive a concurrent run B stage call'); + assert.equal( + fs.readFileSync(path.join(runA.filesDir, 'USER-PROFILE.md'), 'utf8'), + 'run-A content', + 'run A staged content must survive — this is the exact clobber the F1 race causes', + ); + assert.notEqual(runA.entryDir, runB.entryDir, 'two concurrent runs must key to DIFFERENT entry directories'); + + // Both runs' data is independently recoverable — proves the fix does not + // just avoid a crash, it keeps both batches genuinely intact. + assert.equal(fs.readFileSync(path.join(runB.filesDir, 'dev-preferences.md'), 'utf8'), 'run-B content'); + }); +}); + +// --------------------------------------------------------------------------- +// B. The crash window +// --------------------------------------------------------------------------- + +describe('user-artifact-staging: B. the crash window (F19)', () => { + test('B1 (the F19 regression row): crash between wipe and restore, then recovery — byte-identical', (t) => { + const configDir = mktemp('gsd-uas-b1-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'my profile content\n'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): a dead runId + // is required here — this test's OWN process is alive for the whole + // test, so the default runId (process.pid) would make + // recoverOrphanedUserArtifacts's ownerStillLive guard treat this batch + // as belonging to a still-live peer and refuse to recover it, which + // would make this "the F19 regression row" test inert (it would pass + // for the wrong reason — a skip, not a genuine recovery). + stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + + // Simulate the crash: the wipe ran, the process died before restore. + cleanup(destDir); + assert.ok(!fs.existsSync(destDir), 'destDir is gone — this is the F19 crash window'); + + // Next run: recovery, BEFORE any new preserve/wipe cycle. + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 1); + assert.equal( + fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), + 'my profile content\n', + ); + }); + + test('B2 (negative proof): the in-memory-only shape this replaces cannot recover, proving the row is real', (t) => { + // preserveUserArtifacts/restoreUserArtifacts (pre-#2875) held content ONLY + // in a Map — nothing durable ever touched disk between preserve and a + // process death. This reproduces that exact shape inline (no disk write) + // to demonstrate recoverOrphanedUserArtifacts has nothing to find: proof + // that B1's green result comes from the staging fix, not from the test + // being unable to fail. + const configDir = mktemp('gsd-uas-b2-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'never staged to disk'); + const legacyInMemoryMap = new Map([['USER-PROFILE.md', fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8')]]); + void legacyInMemoryMap; // held only in the process's heap — a crash here loses it + + cleanup(destDir); // the crash: process dies right here, map is gone + + // #2875 defect fix: mirror B1's own post-crash sequence EXACTLY (recover, + // THEN recreate destDir, THEN check for the specific file) — the only + // difference from B1 is that stageUserArtifacts was never called. The + // previous version asserted `!fs.existsSync(destDir)` immediately after + // `cleanup(destDir)` deleted it on the line above, which is trivially + // true regardless of anything recovery does and never actually exercised + // recovery's behavior; it was also structurally identical to C5 (no + // staging root at all), so it could not distinguish "recovery correctly + // finds nothing" from "recovery is broken in a way that never finds + // anything, ever". This version recreates destDir (as a real + // preserve/wipe cycle would) and checks the recovered destination + // directly — proving negatively that recovery does not (and cannot) + // resurrect content nothing ever staged to disk. + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result.recovered, [], 'nothing was ever staged to disk — recovery correctly finds nothing'); + fs.mkdirSync(destDir, { recursive: true }); + assert.ok( + !fs.existsSync(path.join(destDir, 'USER-PROFILE.md')), + 'the file is genuinely lost under the pre-#2875 shape — recovery cannot resurrect content that was never staged', + ); + }); + + test('B4: crash before the record is written — nothing restored, half-staged dir left alone', (t) => { + const configDir = mktemp('gsd-uas-b4-cfg-'); + t.after(() => cleanup(configDir)); + const stagingRoot = stagingRootFor(configDir); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'x'); + + const originalWriteFileSync = fs.writeFileSync; + // t.mock.method auto-restores the original after this test — no manual + // try/finally (CONTRIBUTING.md bans try/finally in test bodies). + t.mock.method(fs, 'writeFileSync', (p, ...rest) => { + if (String(p).endsWith('record.json')) throw Object.assign(new Error('crash'), { code: 'EIO' }); + return originalWriteFileSync.call(fs, p, ...rest); + }); + assert.throws(() => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot)); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result.recovered, []); + assert.deepEqual(result.skipped, []); + // The half-staged entry itself is left in place (not a recovery source, + // but also not swept — it is evidence, not garbage). + const entries = fs.readdirSync(stagingRoot); + assert.equal(entries.length, 1, 'the half-staged entry dir is still present for inspection'); + }); + + test('B5: crash after successful restore — staging already discarded, next run finds no orphan', (t) => { + const configDir = mktemp('gsd-uas-b5-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'restored already'); + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot); + cleanup(destDir); + fs.mkdirSync(destDir, { recursive: true }); + restoreStagedUserArtifacts(destDir, staged); + discardStagedUserArtifacts(staged); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result.recovered, [], 'nothing left to recover — the batch was already discarded'); + assert.deepEqual(result.skipped, []); + }); +}); + +// --------------------------------------------------------------------------- +// C. Recovery semantics — the anti-inertness rows +// --------------------------------------------------------------------------- + +describe('user-artifact-staging: C. recovery semantics', () => { + test('C1: orphan exists, destDir file absent — restored', (t) => { + const configDir = mktemp('gsd-uas-c1-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'c1'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId — + // see B1's comment for why the default (this process's own live pid) + // would make this test inert. + stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + cleanup(destDir); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 1); + assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'c1'); + }); + + test('C2: orphan exists, destDir file present — NOT overwritten', (t) => { + const configDir = mktemp('gsd-uas-c2-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'orphaned-content'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId — + // see B1's comment. + stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + // The destDir file was NOT wiped this time — a fresh, different file is present. + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'current-user-content'); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'dest-already-present'); + assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'current-user-content'); + }); + + test('C3: recorded destDir no longer exists — recreated, never throws', (t) => { + const configDir = mktemp('gsd-uas-c3-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'legacy', 'gsd'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'c3'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId — + // see B1's comment. + stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRoot, { runId: '999999' }); + // Remove the WHOLE parent tree, not just destDir. + cleanup(path.join(configDir, 'legacy')); + assert.ok(!fs.existsSync(destDir)); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 1); + assert.equal(fs.readFileSync(path.join(destDir, 'dev-preferences.md'), 'utf8'), 'c3'); + }); + + test('C4: recovery runs before the preserve step — recovered file is then protected by the ordinary cycle', (t) => { + const configDir = mktemp('gsd-uas-c4-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'c4'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId — + // see B1's comment. The SECOND stageUserArtifacts call below (the + // "ordinary preserve step") deliberately keeps the default live runId — + // it is not exercising recovery, only proving the recovered file is + // re-stageable afterward. + stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + cleanup(destDir); + + recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.ok(fs.existsSync(path.join(destDir, 'USER-PROFILE.md')), 'recovered before the ordinary cycle runs'); + + // The ordinary preserve step now sees the recovered file and protects it. + const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot); + assert.deepEqual(staged.names, ['USER-PROFILE.md']); + }); + + test('C5: no staging root at all — no-op, no throw, no directory created', (t) => { + const configDir = mktemp('gsd-uas-c5-cfg-'); + t.after(() => cleanup(configDir)); + const stagingRoot = stagingRootFor(configDir); // never created + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result, { recovered: [], skipped: [] }); + assert.ok(!fs.existsSync(stagingRoot), 'recovery must not create a staging root'); + }); + + test('C6: corrupt/truncated record JSON — ignored, never a crash', (t) => { + const configDir = mktemp('gsd-uas-c6-cfg-'); + t.after(() => cleanup(configDir)); + const stagingRoot = stagingRootFor(configDir); + const entryDir = path.join(stagingRoot, 'deadbeefdeadbeef'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'x'); + fs.writeFileSync(path.join(entryDir, 'record.json'), '{ this is not valid json'); + + assert.doesNotThrow(() => recoverOrphanedUserArtifacts(stagingRoot, configDir)); + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.deepEqual(result.recovered, []); + // Malformed record is left alone for inspection, not swept. + assert.ok(fs.existsSync(entryDir), 'malformed staging entry is left in place, not deleted'); + }); + + test('C8: configDir is an explicit parameter, not derived from stagingRoot\'s path shape — a record naming a destDir outside the EXPLICITLY-passed configDir is refused', (t) => { + // stagingRoot deliberately lives OUTSIDE configDir entirely (no nested + // relationship at all) — the two-directories-up derivation this replaced + // would have computed a nonsense confinement root here. The explicit + // parameter is what makes the confinement decision correct regardless of + // where stagingRoot physically lives. + const configDir = mktemp('gsd-uas-c8-cfg-'); + const unrelatedStagingParent = mktemp('gsd-uas-c8-unrelated-'); + const outsideConfigDir = mktemp('gsd-uas-c8-outside-'); + t.after(() => { cleanup(configDir); cleanup(unrelatedStagingParent); cleanup(outsideConfigDir); }); + + const stagingRoot = path.join(unrelatedStagingParent, 'wherever', 'staging'); + const entryDir = path.join(stagingRoot, 'c8entry0000000000'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'attacker or stale data'); + // destDir resolves under outsideConfigDir, NOT under configDir. + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.join(outsideConfigDir, 'gsd-core'), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString() }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 0, 'must refuse — destDir is outside the explicitly-passed configDir'); + assert.equal(result.skipped.length, 1); + assert.equal(result.skipped[0].reason, 'destDir-outside-confinement'); + assert.ok( + !fs.existsSync(path.join(outsideConfigDir, 'gsd-core', 'USER-PROFILE.md')), + 'must never write to the recorded destDir when it escapes the explicit configDir', + ); + }); + + // Confinement/symlink rows (dangling-symlink destination, symlinked + // ancestor, separator-in-name) live in tests/install-write-confinement.test.cjs + // as E6-E9, alongside E1-E5 — this suite's own file-count ratchet keeps + // confinement rows there (see this file's own header doc comment). + + // #2875 defect fix: recoverOrphanedUserArtifacts's "never throws" contract + // was false — mkdirSync/stagedCopy/the final rmSync were all unguarded, so + // one unrecoverable entry (e.g. a `files/` that is unexpectedly a + // directory, causing the symlink-safe copy to throw) propagated out of the + // whole function, and — because it threw BEFORE that entry was ever + // cleaned up — permanently bricked install/uninstall on every future run. + test('C13: an entry that cannot be recovered (a directory staged where a file is expected) never throws, and other entries still recover', (t) => { + const configDir = mktemp('gsd-uas-c13-cfg-'); + t.after(() => cleanup(configDir)); + const stagingRoot = stagingRootFor(configDir); + + // Entry 1: genuinely broken — files/BROKEN.md is a DIRECTORY, not a file, + // which the symlink-safe copy cannot handle. + const brokenDestDir = path.join(configDir, 'broken-dest'); + fs.mkdirSync(brokenDestDir, { recursive: true }); + const brokenEntryDir = path.join(stagingRoot, 'c13brokenentry00'); + fs.mkdirSync(path.join(brokenEntryDir, 'files', 'BROKEN.md'), { recursive: true }); // a DIR, not a file + fs.writeFileSync( + path.join(brokenEntryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(brokenDestDir), names: ['BROKEN.md'], timestamp: new Date().toISOString() }), + ); + + // Entry 2: a perfectly ordinary, recoverable batch. + const goodDestDir = path.join(configDir, 'good-dest'); + fs.mkdirSync(goodDestDir, { recursive: true }); + fs.writeFileSync(path.join(goodDestDir, 'USER-PROFILE.md'), 'good content'); + // #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId — + // see B1's comment. + stageUserArtifacts(goodDestDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' }); + cleanup(path.join(goodDestDir, 'USER-PROFILE.md')); + + let result; + assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); }); + assert.ok( + result.recovered.some((r) => r.name === 'USER-PROFILE.md' && r.destDir === path.resolve(goodDestDir)), + 'the OTHER (good) batch must still be recovered even though a sibling batch is broken', + ); + assert.ok(fs.existsSync(brokenEntryDir), 'the broken entry must be LEFT IN PLACE, not silently swept, since it was never actually recovered'); + }); + + test('C14 (#2875 F1 residual defect): a peer recovery pass must not sweep an entry whose owning process is still alive', (t) => { + // recoverOrphanedUserArtifacts runs as the FIRST statement of both + // install() and uninstall() — a second run's recovery pass executing + // while a first run is still between stageUserArtifacts's commit and its + // own restore/discard is the ordinary case, not an exotic interleaving. + // Reproduces it directly: run A stages with the DEFAULT runId (this test + // process's OWN real pid — genuinely alive for the whole test, standing + // in for "run A still in flight"), then a peer recovery pass runs while + // A's destDir is mid-wipe (removed, not yet restored). + const configDir = mktemp('gsd-uas-c14-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'run-A content, still in flight'); + const runA = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot); + assert.ok(fs.existsSync(runA.recordPath), 'run A committed its record'); + + // Run A's own wipe, in progress — destDir is gone, restore has not + // happened yet. A PEER run's recovery pass runs here. + cleanup(destDir); + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + + // A live owner's entry must be left COMPLETELY alone — not recovered + // (which would race run A's own in-progress cycle) and not swept. + assert.ok(fs.existsSync(runA.recordPath), 'run A entry must survive a peer recovery pass while run A is still alive'); + assert.equal(result.recovered.length, 0, 'a live owner\'s file must not be recovered on its behalf either'); + assert.deepEqual(result.skipped, [{ entryDir: runA.entryDir, reason: 'owner-still-live' }]); + + // Run A can still complete its OWN cycle normally afterward — the guard + // only protects against a PEER touching it, never blocks the owner. + fs.mkdirSync(destDir, { recursive: true }); + restoreStagedUserArtifacts(destDir, runA); + discardStagedUserArtifacts(runA); + assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'run-A content, still in flight'); + }); + + test('C15: a dead pid (ESRCH) is recovered normally — the liveness guard never blocks a genuine orphan', (t) => { + const configDir = mktemp('gsd-uas-c15-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + // A pid that is essentially guaranteed not to exist right now. + const deadPid = '999999'; + const entryDir = path.join(stagingRoot, 'c15deadpidentry0'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'orphaned content'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString(), runId: deadPid }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 1, 'a dead pid must never be treated as a live owner'); + assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'orphaned content'); + assert.ok(!fs.existsSync(entryDir), 'fully recovered — swept as before this fix'); + }); + + test('C16: a missing/non-numeric runId (a legacy or hand-edited record) is never treated as live forever', (t) => { + const configDir = mktemp('gsd-uas-c16-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + // A pre-this-fix record: no runId field at all. + const entryDir = path.join(stagingRoot, 'c16legacyentry00'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'legacy orphaned content'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString() }), + ); + + // #2875 defect fix: the previous version called recoverOrphanedUserArtifacts + // TWICE and checked the SECOND call's result — but a successful recovery + // sweeps the entry (rmSync) once it is fully accounted for, so the first + // (assert.doesNotThrow) call already recovered and removed it, leaving + // the second call's result empty regardless of whether recovery itself + // works. Capture the FIRST call's result instead — the same pattern C13 + // already uses. + let result; + assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); }); + assert.equal(result.recovered.length, 1, 'a record with no runId must recover normally, never blocked as "live forever"'); + assert.ok(!fs.existsSync(entryDir)); + }); + + test('C17: an apparently-live pid past OWNER_LIVENESS_GRACE_MS is treated as orphaned regardless (the pid-reuse guard)', (t) => { + const configDir = mktemp('gsd-uas-c17-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + // runId is THIS test process's own real (genuinely alive) pid, but the + // record's timestamp is far in the past — simulates a crashed run whose + // pid an unrelated, currently-alive process now happens to occupy. + const entryDir = path.join(stagingRoot, 'c17stalelivepid0'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'stale orphaned content'); + const staleTimestamp = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1 hour ago + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: staleTimestamp, runId: String(process.pid) }), + ); + + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir); + assert.equal(result.recovered.length, 1, 'a stale claim past the grace window must recover regardless of apparent liveness'); + assert.ok(!fs.existsSync(entryDir)); + }); + + test('C18: a fresh, apparently-live claim just inside the clock-seam-injected grace window is protected', (t) => { + const configDir = mktemp('gsd-uas-c18-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + const entryDir = path.join(stagingRoot, 'c18freshlivepid0'); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'fresh live content'); + const recordTimestamp = Date.parse('2001-01-01T00:00:00.000Z'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ + destDir: path.resolve(destDir), + names: ['USER-PROFILE.md'], + timestamp: new Date(recordTimestamp).toISOString(), + runId: String(process.pid), + }), + ); + + // Injected clock — never the real wall clock — pinned to 4 minutes after + // the record's own timestamp, inside the 5-minute grace window. + const fakeClock = { now: () => recordTimestamp + 4 * 60 * 1000 }; + const result = recoverOrphanedUserArtifacts(stagingRoot, configDir, { clock: fakeClock }); + assert.equal(result.recovered.length, 0, 'still within the grace window — must not recover on the live owner\'s behalf'); + assert.deepEqual(result.skipped, [{ entryDir, reason: 'owner-still-live' }]); + assert.ok(fs.existsSync(entryDir), 'must not be swept while apparently live and fresh'); + }); + + // CLAUDE.md boundary coverage: OWNER_LIVENESS_GRACE_MS (5 * 60 * 1000 = + // 300000ms) is a budget limit — exercised at limit-1, limit, and limit+1, + // not merely "somewhere inside" (C18's 4-minute probe) or "somewhere + // outside" (C17's 1-hour probe). The guard's own predicate is strict-less- + // than (`clock.now() - recordMs < OWNER_LIVENESS_GRACE_MS`), so the + // boundary itself (limit, exactly 300000ms elapsed) flips to "no longer + // protected" — these three rows pin that exact flip. + function ownerLivenessAtElapsed(t, elapsedMs) { + const configDir = mktemp(`gsd-uas-c19-${elapsedMs}-cfg-`); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + const stagingRoot = stagingRootFor(configDir); + + const entryDir = path.join(stagingRoot, `c19b${elapsedMs}livepid0`.slice(0, 16)); + fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true }); + fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'boundary content'); + const recordTimestamp = Date.parse('2001-01-01T00:00:00.000Z'); + fs.writeFileSync( + path.join(entryDir, 'record.json'), + JSON.stringify({ + destDir: path.resolve(destDir), + names: ['USER-PROFILE.md'], + timestamp: new Date(recordTimestamp).toISOString(), + runId: String(process.pid), + }), + ); + + const fakeClock = { now: () => recordTimestamp + elapsedMs }; + return { entryDir, result: recoverOrphanedUserArtifacts(stagingRoot, configDir, { clock: fakeClock }) }; + } + + test('C19: OWNER_LIVENESS_GRACE_MS boundary — limit-1 (299999ms elapsed) is still protected', (t) => { + const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000 - 1); + assert.equal(result.recovered.length, 0, 'one ms inside the grace window must still protect the live owner\'s claim'); + assert.deepEqual(result.skipped, [{ entryDir, reason: 'owner-still-live' }]); + assert.ok(fs.existsSync(entryDir)); + }); + + test('C20: OWNER_LIVENESS_GRACE_MS boundary — limit (300000ms elapsed exactly) flips to orphaned', (t) => { + const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000); + assert.equal(result.recovered.length, 1, 'exactly at the grace window boundary the guard\'s strict `<` no longer protects — must recover'); + assert.ok(!fs.existsSync(entryDir)); + }); + + test('C21: OWNER_LIVENESS_GRACE_MS boundary — limit+1 (300001ms elapsed) is orphaned', (t) => { + const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000 + 1); + assert.equal(result.recovered.length, 1, 'one ms past the grace window must recover — same side of the boundary as limit'); + assert.ok(!fs.existsSync(entryDir)); + }); +}); + +// --------------------------------------------------------------------------- +// D. Fs seam completeness +// --------------------------------------------------------------------------- + +describe('user-artifact-staging: D. fs seam completeness', () => { + test('D1: staging under an injected fake adapter resolves; no real path created', (t) => { + const configDir = mktemp('gsd-uas-d1-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + const stagingRoot = stagingRootFor(configDir); + + const store = new Map(); + const norm = (p) => path.normalize(String(p)); + store.set(norm(destDir), { type: 'dir' }); + store.set(norm(path.join(destDir, 'USER-PROFILE.md')), { type: 'file', content: 'fake-fs-content' }); + let fakeRmSyncCalls = 0; + const fake = { + existsSync: (p) => store.has(norm(p)), + mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); }, + // #2875 defect fix: stageUserArtifacts's entryDir-clearing step calls + // installFs().rmSync unconditionally — a partial fake omitting it + // previously fell through to REAL fs.rmSync silently (install-fs- + // adapter.cts's PARTIAL-ADAPTER TRAP), which this test's own negative + // assertion below could not detect (it only checked `stagingRoot`, + // which real rmSync on a non-existent real path no-ops against + // harmlessly with `force: true` — vacuously true either way). + rmSync: (p) => { + fakeRmSyncCalls++; + const n = norm(p); + store.delete(n); + const prefix = n.endsWith(path.sep) ? n : n + path.sep; + for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k); + }, + writeFileSync: (p, data) => { store.set(norm(p), { type: 'file', content: data }); }, + lstatSync: (p) => { + const e = store.get(norm(p)); + if (!e) { const err = new Error('ENOENT'); err.code = 'ENOENT'; throw err; } + return { isFile: () => e.type === 'file', isDirectory: () => e.type === 'dir', isSymbolicLink: () => false }; + }, + copyFileSync: (src, dest) => { + const e = store.get(norm(src)); + store.set(norm(dest), { type: 'file', content: e ? e.content : '' }); + }, + }; + + const staged = withInstallFs(fake, () => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot)); + assert.deepEqual(staged.names, ['USER-PROFILE.md']); + assert.ok(store.has(norm(path.join(staged.filesDir, 'USER-PROFILE.md'))), 'landed in the fake store'); + assert.ok(fakeRmSyncCalls > 0, 'the entryDir-clearing step must route through the FAKE rmSync, proving this is a real negative check'); + assert.ok(!fs.existsSync(stagingRoot), 'no real filesystem path was ever created'); + }); + + test('D3: staging touches zero real fs under a fake adapter (poison by path)', (t) => { + const configDir = mktemp('gsd-uas-d3-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + const stagingRoot = stagingRootFor(configDir); + + const store = new Map(); + const norm = (p) => path.normalize(String(p)); + store.set(norm(destDir), { type: 'dir' }); + store.set(norm(path.join(destDir, 'USER-PROFILE.md')), { type: 'file', content: 'x' }); + const fake = { + existsSync: (p) => store.has(norm(p)), + mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); }, + // #2875 defect fix: this fake previously omitted rmSync, so + // stageUserArtifacts's entryDir-clearing step (installFs().rmSync) + // fell through to REAL fs.rmSync (install-fs-adapter.cts's + // PARTIAL-ADAPTER TRAP) — which is exactly what this test's own + // poisoning below is supposed to catch, and did: `real fs.rmSync + // touched under a fake adapter`. Implementing it here is the fix. + rmSync: (p) => { + const n = norm(p); + store.delete(n); + const prefix = n.endsWith(path.sep) ? n : n + path.sep; + for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k); + }, + writeFileSync: (p, data) => { store.set(norm(p), { type: 'file', content: data }); }, + lstatSync: (p) => { + const e = store.get(norm(p)); + if (!e) { const err = new Error('ENOENT'); err.code = 'ENOENT'; throw err; } + return { isFile: () => e.type === 'file', isDirectory: () => e.type === 'dir', isSymbolicLink: () => false }; + }, + copyFileSync: (src, dest) => { + const e = store.get(norm(src)); + store.set(norm(dest), { type: 'file', content: e ? e.content : '' }); + }, + }; + + // Poison every real fs method this call tree could touch — a gap here + // fires as the poisoned method throwing, never a silent pass (Phase 5's + // F2 pattern, install-fs-adapter.cts's own doc comment). + // t.mock.method (no manual try/finally — CONTRIBUTING.md bans try/finally + // in test bodies). Restored EXPLICITLY right after use, rather than left + // to the mock tracker's own end-of-test auto-restore: this suite's + // `t.after(() => cleanup(configDir))` above runs its real `fs.rmSync` + // during teardown, and `t.after` hooks fire BEFORE the mock tracker's + // auto-restore — an un-restored poisoned `rmSync` would make that + // cleanup itself throw. + const realFs = require('node:fs'); + const poisoned = ['existsSync', 'mkdirSync', 'writeFileSync', 'lstatSync', 'copyFileSync', 'symlinkSync', 'readlinkSync', 'rmSync']; + const mocks = poisoned.map((m) => t.mock.method(realFs, m, () => { throw new Error(`real fs.${m} touched under a fake adapter`); })); + const staged = withInstallFs(fake, () => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot)); + for (const mockFn of mocks) mockFn.mock.restore(); + assert.deepEqual(staged.names, ['USER-PROFILE.md']); + }); + + test('D4 (correctness, not error-handling): adapter throws mid-stage — the failure propagates', (t) => { + const configDir = mktemp('gsd-uas-d4-cfg-'); + t.after(() => cleanup(configDir)); + const destDir = path.join(configDir, 'gsd-core'); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'must-survive'); + const stagingRoot = stagingRootFor(configDir); + + const originalMkdirSync = fs.mkdirSync; + // t.mock.method (no manual try/finally — CONTRIBUTING.md bans try/finally + // in test bodies); restored explicitly right after use rather than left + // to end-of-test auto-restore, matching D3's rationale above. + const mkdirMock = t.mock.method(fs, 'mkdirSync', (p, ...rest) => { + if (String(p).includes('.gsd-staging')) { + throw Object.assign(new Error('simulated EACCES creating staging dir'), { code: 'EACCES' }); + } + return originalMkdirSync.call(fs, p, ...rest); + }); + assert.throws( + () => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot), + /EACCES|simulated/, + 'staging failure must propagate — a caller cannot proceed to wipe having staged nothing', + ); + mkdirMock.mock.restore(); + // The source file is untouched — nothing wiped, because the throw + // happened before any caller-side wipe could run. + assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'must-survive'); + }); + + test('D2 (regression): copyPreservingSymlink is unaffected for its existing ambient-fs migration caller', (t) => { + const srcDir = mktemp('gsd-uas-d2-src-'); + const destDir = mktemp('gsd-uas-d2-dest-'); + t.after(() => { cleanup(srcDir); cleanup(destDir); }); + + const srcFile = path.join(srcDir, 'a.txt'); + fs.writeFileSync(srcFile, 'plain-file-content'); + const destFile = path.join(destDir, 'a.txt'); + // No withInstallFs wrap — ambient default resolves to real fs, exactly + // the migration engine's own call shape. + copyPreservingSymlink(srcFile, destFile); + assert.equal(fs.readFileSync(destFile, 'utf8'), 'plain-file-content'); + + const linkSrc = path.join(srcDir, 'link.txt'); + fs.symlinkSync(srcFile, linkSrc); + const linkDest = path.join(destDir, 'link.txt'); + copyPreservingSymlink(linkSrc, linkDest); + assert.ok(fs.lstatSync(linkDest).isSymbolicLink()); + }); +}); + +// --------------------------------------------------------------------------- +// E. parseOwnerPid — property-based (CLAUDE.md "Property-Based Testing": +// parsers must carry at least one fast-check property test) +// --------------------------------------------------------------------------- + +describe('user-artifact-staging: E. parseOwnerPid (property-based)', () => { + test('E-P1: for any string, parseOwnerPid agrees with the documented contract — /^[1-9][0-9]*$/ AND Number.isSafeInteger, else null', () => { + fc.assert( + fc.property(fc.string({ maxLength: 40 }), (s) => { + const isPlainPositiveIntegerLiteral = /^[1-9][0-9]*$/.test(s); + const n = Number(s); + const expected = isPlainPositiveIntegerLiteral && Number.isSafeInteger(n) ? n : null; + assert.equal(parseOwnerPid(s), expected); + }), + ); + }); + + test('E-P2: every valid positive-integer pid string round-trips to the SAME number, never a string, never NaN', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: Number.MAX_SAFE_INTEGER }), (n) => { + const s = String(n); + assert.equal(parseOwnerPid(s), n); + }), + ); + }); + + test('E-P3: "0", any negative-integer string, and any non-string value always resolve to null (never a live-forever claim)', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant('0'), + fc.integer({ min: Number.MIN_SAFE_INTEGER, max: -1 }).map(String), + fc.anything().filter((v) => typeof v !== 'string'), + ), + (input) => { + assert.equal(parseOwnerPid(input), null); + }, + ), + ); + }); +});