enh(#2875): materialization primitives — durable user-artifact staging and descriptor-authoritative agents (#3600)
* fix(#2875): stage user artifacts durably across install wipes (#1874-F19) preserveUserArtifacts held user files only in an in-memory Map across the wipe, so any process death between preserve and restore lost them outright. Seven call sites, not the four the issue records. Three of them never called the helper at all - they open-coded the same read/wipe/write - so searching for callers under-counted by construction; the extra sites were found by sweeping for the pattern instead. The worst is the mainline install path, where the crash window spans the entire gsd-core tree copy rather than a single rmSync. Adds src/user-artifact-staging.cts: durable on-disk staging with a record written after the copies land as the commit point, plus recovery of orphaned batches on the next run - without recovery the staged bytes survive but the user's file is still gone, which would pass its own test while delivering nothing. Routes copyPreservingSymlink through installFs() so staging cannot bypass the install fs seam, and reunites its symlink-safety docblock with the function it documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-3574 with four claims disproved by implementation Implementing Phase 6 disproved four statements the ADR rests on. The central decision - no single materializer - is unaffected and stands. Corrected: decision 3 was already satisfied, so nothing was extracted; the agents-bypass runtime set omitted claude, kilo and opencode, and closing it needed three new pieces of descriptor contract rather than proceeding on its own terms; three of the four blockers the layout comment names were already stale; and F19 is seven call sites, not four. Records the generalizable lesson: the defect is the pattern of holding user data in memory across a wipe, not the helper, so searching for callers of the helper under-counts by construction. Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and notes that copyPreservingSymlink needed routing through the install fs seam before it could be reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close dangling-symlink blind spot and harden staging recovery An adversarial review found the F19 staging work shipped red and unsafe. Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween missed dangling symlinks in both its root check and its per-segment walk, because it probed with existsSync, which is false for a link whose target does not exist. Fixing only the new module would have reused a guard that was itself blind. This guard protects the whole install tree. Recovery no longer throws: it degrades per entry and per file, so one bad batch cannot block the others. Previously an unrecoverable entry propagated out of the first statement of install and uninstall, before the cleanup that would have removed it - wedging the installer permanently. Partial fs adapters now throw on any omitted method instead of silently reaching the real filesystem, closing the trap that let a test poison list pass while real IO happened. Staged names must be flat, recovery refuses a dangling destination symlink, and a batch whose recovery genuinely failed is no longer swept - it was discarding the only durable copy of the file it had just failed to restore. Replaces three tests that could not fail, including the one labelled negative proof. Known limitation, documented not closed: concurrent installs sharing a staging key can still lose a batch. A real fix needs a cross-process lock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enh(#2875): make the descriptor authoritative for the agents kind Deletes the inline agent-staging loop in bin/install.js and the _DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from its capability descriptor instead of an inline hostBehaviors dispatch. Closing it needed three pieces of contract the descriptor pipeline never had, all reducible to one missing input - per-agent resolution context: a frontmatter-extensions step for claude's effort and disallowedTools, per-agent model-override resolution for kilo and opencode, and a named branding converter for hermes, whose rewrite data was already declared. Seven runtimes were on the loop, not the six the design recorded - kimi-code was found by a golden fixture, not by analysis. claude-local and kimi-code both silently lost their agents mid-change; the fixtures caught both and the cause was fixed rather than the fixtures regenerated. A parity harness gates the migration: both pipelines over identical inputs, byte-identical output including filenames, per runtime. It is demonstrated red before being trusted. Surface and install paths converge for all seven, which also fixes surface previously writing no agents for these runtimes. Codex's config.toml strip stays put - it mutates host config, which no descriptor kind models. Also routes install-model-override-resolver and install-effort-resolver through the install fs seam. Both leaked real filesystem IO from the install call tree; the stricter adapter is what exposed them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): record the agents-descriptor migration and correct the ADR count The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host integration guide told readers to join a set that is gone. Replaces that with what is now true - declare an agents entry and it installs, on the surface path as well as install - and points anyone needing a per-agent transform at the three extension points rather than at a new inline branch. Corrects the ADR amendment: seven runtimes were on the inline loop, not six. kimi-code was found by a golden fixture going red, not by reading. That is the third short count this phase, all from enumerating by symbol or set membership when the thing that matters is a behavior. Adds the Changed changeset for the surface-path convergence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): amend ADR-2866 - claude global always wrote agents on disk The claude row's global=[skills] described what capability.json declared, not what the installer wrote. bin/install.js's inline agent-staging loop was never scope-gated and never consulted the descriptor, so a claude --global install has always written agents/gsd-*.md. Phase 6 closes the gap by deleting that loop and declaring agents on claude's descriptor at global scope. On-disk bytes are unchanged - the golden fixtures did not move, which is the evidence that the descriptor, not the installer, was incomplete. #2218 is unaffected: agents are not trigger-bearing, so the wider row does not introduce a new shadowing case. Records the warning that an incomplete descriptor is invisible while a second code path silently does its work, and only surfaces when the two are forced into agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close review findings across staging, agents and the parity harness Two independent reviews of this branch found defects the local gates missed. Security: a dangling symlink at a migration destination allowed writing outside configDir - the same class this change claimed to close, missed at the terminal write of the flow being added. The staging-root resolver threw as the first statement of install and uninstall, so a hostile symlink bricked both, and symlinked-configDir users lost uninstall as well as install; it now degrades instead of aborting. Recovery gained a source-side symlink check and now refuses a relative destDir, which resolved against cwd. Converter dispatch gained a runtime allowlist - lint-time validation stopped mattering once this branch promoted that dispatch from the surface path to real installs. Correctness: claude --local --minimal exited 1 because the minimal profile legitimately yields zero agents and the new path treated that as a failure. cline --local silently lost its agents - its descriptor declared none while the deleted loop wrote them unconditionally. The agents prune was widened to any gsd-* entry and destroyed user files it never owned. The parity harness, on which the migration's safety argument rested, drove a synthetic registry and never byte-compared the shipped descriptors; two of its trap rows could not fail. It now drives the real registry across 13 runtime-scope rows including kimi-code and cline-local, and its red-proof is demonstrated by corrupting a live capability.json. Three goldens that had encoded the cline regression as expected behavior were corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): close findings from both mandated review engines /security-review found the staging source-side walk honouring GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write destination. A symlinked files/ component dereferenced because copyPreservingSymlink lstats the leaf only, so an intermediate link is followed. The source walk no longer honours the opt-in; the destination check still does. /code-review spec axis found this branch had reintroduced its own bug: migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after the legacy dir was wiped and before the staged batch was restored, so a planted symlink bricked uninstall permanently and orphaned the batch. Refusal kept, abort removed. kimi-code local silently lost its agents, the same class as the cline bug, and the parity harness recorded that exclusion as intentional - the third test in this branch to pin a regression as correct. --minimal now creates an empty agents/ dir that never existed. Behaviour restored rather than softening the changeset, so its byte-identical claim stays true. Standards axis: try/finally removed from twelve test bodies, fast-check properties added for parseOwnerPid, boundary coverage at the grace window and the ancestor-probe depth, a parity assertion for the staging-root helper duplicated across two files, and the 8-deep config walk deduplicated. Records 60-review.json with every finding and disposition from five passes, including the smells left unfixed and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2875): prune stale agents unconditionally in minimal mode The previous round stopped an empty agents/ directory being created when the resolved profile yields no agents. That was implemented by skipping the agents kind entirely, which also skipped its stale-agent prune - so a full to minimal downgrade left stale gsd-* agents behind. The deleted inline loop pruned unconditionally and only skipped writing. Those are three separate conditions, not one: prune always, write only when there is something to write, create the directory only when writing. Both call sites now run _removeGsdEntries before the empty-staged early exit. The symlink-escape guard moved with it, since the prune also touches dest. Codex .toml agents and the config.toml stanzas are cleaned again, and user-owned agents are still preserved. The agents/ directory is left in place after a prune empties it, matching every sibling kind - none of them remove the destination directory itself. Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh install, so fixture generation is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2875): document interrupted-install recovery for user-owned files The durable-staging fix is invisible to the user it protects. Someone whose install died mid-flight has no way to know USER-PROFILE.md was staged before the delete, that the next run restores it, or that recovery happens at the start of that run rather than in the background. Written as the task the user has - finish the interrupted command - rather than as a description of the mechanism, and states what it will not do: overwrite a file already present, or touch staging belonging to another install still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2875): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2875): assert the J8 model override without building a regex CodeQL flagged incomplete string escaping: the assertion interpolated the override value into a RegExp while escaping only forward slashes, which is meaningless in a constructor, leaving real metacharacters unescaped. The failure direction was the dangerous one - a metacharacter would have made the match more permissive, so the row would pass when it should fail. That matters here because J8 exists precisely because an earlier revision was a tautology; the rewrite reintroduced a different way for the same assertion to stop discriminating. Replaced with a line-wise exact match, so no regex is constructed at all. Swept the other test files this branch adds; no sibling instances. lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full metachar-escape copy, so a single slash replace slipped under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> };
|
||||
@@ -41,10 +58,10 @@ interface EffortConfig {
|
||||
* mask broken configs (review finding #5).
|
||||
*/
|
||||
function _readGsdConfigFile(absPath: string, label: string): Record<string, unknown> | 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<string, unknown> | 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,
|
||||
};
|
||||
|
||||
@@ -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<string, (content: string, skillName: str
|
||||
convertClaudeCommandToKimiCodeSkill: runtimeArtifactConversion.convertClaudeCommandToKimiCodeSkill,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-artifact preservation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save user-generated files from destDir to an in-memory map before a wipe.
|
||||
*
|
||||
* @param destDir - Directory that is about to be wiped
|
||||
* @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
||||
* @returns Map of fileName → file content (only entries that existed)
|
||||
*/
|
||||
function preserveUserArtifacts(destDir: string, fileNames: string[]): Map<string, string> {
|
||||
const saved = new Map<string, string>();
|
||||
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<string, string>): 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. `<configDir>/USER-PROFILE.md ->
|
||||
// <outside>/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: `<configDir>/.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<string, string>, 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<string
|
||||
if (runtime) {
|
||||
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope as any);
|
||||
const skillsKindEntry = layout.kinds.find((k: any) => 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<string
|
||||
// Legacy fallback for callers that have not yet been updated to pass runtime
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
||||
}
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
if (installFs().existsSync(skillFile)) return false;
|
||||
return { skillFile: path.join(skillDir, 'SKILL.md'), installRoot };
|
||||
}
|
||||
|
||||
function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string, string>, 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.
|
||||
// `<installRoot>/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<string, string> | null = null;
|
||||
let stagedLegacyArtifacts: ReturnType<typeof userArtifactStaging.stageUserArtifacts> | 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<string, string>();
|
||||
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<string, string> | null {
|
||||
function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: string = 'global'): ReturnType<typeof userArtifactStaging.stageUserArtifacts> | null {
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
|
||||
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
|
||||
// After #1367, Claude-local uses flat commands/gsd-<cmd>.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<string, string> | 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<typeof userArtifactStaging.stageUserArtifacts> | 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<string, string>();
|
||||
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,
|
||||
|
||||
@@ -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 `<configDir>/.gsd-staging/<key>` 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<keyof InstallFsAdapter> = 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>): InstallFsAdapter {
|
||||
const guarded = {} as Record<keyof InstallFsAdapter, unknown>;
|
||||
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<T>(partial: Partial<InstallFsAdapter> | undefined, fn: () => T): T {
|
||||
if (!partial) return fn();
|
||||
const previous = current;
|
||||
current = { ...REAL_ADAPTER, ...partial };
|
||||
current = buildGuardedAdapter(partial);
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
|
||||
231
src/install-model-override-resolver.cts
Normal file
231
src/install-model-override-resolver.cts
Normal file
@@ -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.<runtime>.<tier>` > 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<string, unknown> | 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<string, Record<string, string>> };
|
||||
// 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<string, string> | 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<string, string>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective per-agent `model_overrides` for the install path.
|
||||
*
|
||||
* Merges `~/.gsd/defaults.json` (global) with per-project
|
||||
* `<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<string, string> | null {
|
||||
const global = readGsdGlobalModelOverrides(options);
|
||||
|
||||
let projectOverrides: Record<string, string> | 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<string, string>;
|
||||
}
|
||||
} 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 `<targetDir>/.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<string, unknown> | 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.<runtime>.<tier>)
|
||||
* 3. null (omit — J7: the frontmatter key must not appear, not `null`/`""`)
|
||||
*/
|
||||
function resolveAgentModelOverride(
|
||||
agentName: string,
|
||||
modelOverrides: Record<string, string> | 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,
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <codex_agent_role> 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: <value>` 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: <value>` 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: <value>` 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<string, string> = {
|
||||
'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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,11 @@ import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
|
||||
const conversionExports = runtimeArtifactConversion as Record<string, unknown> & {
|
||||
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<string> | undefined;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
validNames = (require('./capability-validator.cjs') as { VALID_CONVERTER_NAMES: Set<string> }).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.<rt>.<tier>` > 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.<runtime>.<tier> > 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<st
|
||||
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
|
||||
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir });
|
||||
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
|
||||
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution };
|
||||
// #2875 Part 2 (row I1): layout.configDir is this call's install root.
|
||||
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution, targetDir: layout.configDir };
|
||||
|
||||
const tempDirsToClean: string[] = [];
|
||||
// #1575: When the surface has no state modifications AND the base profile is
|
||||
|
||||
812
src/user-artifact-staging.cts
Normal file
812
src/user-artifact-staging.cts
Normal file
@@ -0,0 +1,812 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* User Artifact Staging Module — #2875 (epic #2866 Phase 6), governed by
|
||||
* ADR-3574 and `.gsd/phase/feat-2875-materialization-primitives/40-design.md`
|
||||
* Part 1.
|
||||
*
|
||||
* Durable, on-disk staging for user-owned artifacts (`USER_OWNED_ARTIFACTS`,
|
||||
* `install-engine.cts`) across the preserve → wipe → restore window that
|
||||
* `preserveUserArtifacts`/`restoreUserArtifacts` previously held only in an
|
||||
* in-memory `Map` (#1874-F19). A process death between preserve and restore —
|
||||
* Ctrl-C, OOM, a converter throw mid-copy — silently discarded the map,
|
||||
* losing the user's file forever. Staging to disk closes that window;
|
||||
* `recoverOrphanedUserArtifacts` closes the OTHER half of the fix: a staged
|
||||
* copy nothing ever reads back is bytes-safe but user-visibly lost — the
|
||||
* #1879-F15 inert-fix failure mode 40-design.md's "inertness trap" section
|
||||
* names explicitly. See that doc for the full rationale.
|
||||
*
|
||||
* Synchronous only, matching install-fs-adapter.cts's documented contract —
|
||||
* every fs call in this module routes through `installFs()`.
|
||||
*
|
||||
* Staging layout, all under a caller-supplied `stagingRoot` (by convention
|
||||
* `<configDir>/.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`):
|
||||
*
|
||||
* <stagingRoot>/<sha256(destDir)-16>-<sha256(runId)-8>/record.json — the commit point
|
||||
* <stagingRoot>/<sha256(destDir)-16>-<sha256(runId)-8>/files/<name> — 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. `<configDir>/linkdir
|
||||
* -> <outside>`, a record naming `<configDir>/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/<name>` 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<string, string>;
|
||||
}
|
||||
|
||||
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 (`<stagingRoot>/<key>/`). */
|
||||
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/<name>` 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<StagingRecord> | 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,
|
||||
};
|
||||
Reference in New Issue
Block a user