* fix(#2911): resolve surface re-stage destinations the way the installer does Two writers computed the same destination differently. The installer honors a skills-kind home override; the surface re-stage ignored it and always resolved against configDir. For a global Codex install that override points at $HOME/.agents, so every re-stage built a second GSD-managed skill tree under $CODEX_HOME alongside the correct one, with nothing indicating which was live. Honors the override as a fallback, never a replacement -- runtimes without one still resolve against configDir, which is most of them. The real deliverable is the parity test, not the one-expression fix: it walks every runtime in the registry across both scopes, computes the installer and surface destinations, and fails naming the runtime if they ever disagree. Today only Codex global carries an override, so it discriminates on exactly one runtime -- stating that plainly rather than implying broader coverage -- but it is derived from the registry, so a newly-added runtime is covered without anyone remembering to add it. Two further defects fixed rather than deferred: - The legacy dev-preferences migration carried the identical defect, which the issue flagged as a latent instance of the same shape. - Fixing it exposed a symlink-escape guard confined against the wrong root: it checked the span between configDir and the skill dir, but a home override moves the skill dir outside configDir entirely, so the span was meaningless and threw a false-positive escape. Now confined against the install root the destination actually resolves under. The guard is unchanged in strength and still honors its opt-in; only the root it measures from is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2911): honor the home override in the fourth destination writer too Adversarial review found a writer the fix had missed: the opencode-family skills installer resolved its destination and its symlink guard against targetDir, never consulting the skills-kind home override, while its three siblings all already honored it. Pre-existing and currently dormant -- it is reachable only for the combined-family runtimes, and none of them declares an override today, so no user is affected right now. Fixed anyway rather than left as a latent instance of the same shape, which is exactly what this issue asked for in the case of the legacy migration. Mirrors the shape used for the other three: a single installRoot local that both the destination and the guard derive from, so the two cannot drift apart. The guard's message now names the root it actually confined against. Coverage extended to this writer and proven non-theatre: reverting the change in a scratch build makes it fail for both combined-family runtimes. Enumerated every remaining site that computes a destination from destSubpath or calls the confinement helper -- install and uninstall paths, the surface module, the read-side skills-root reporter. All honor the override or structurally cannot express one. No fifth defect. The one adjacent shape, the flat command directory, reads a different descriptor field that no kind declares an override for in the current schema; noted rather than papered over with a fallback for a field that cannot exist. Verified no behavior change for the affected runtimes today: normalized file-tree hashes before and after are identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2911): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -343,24 +343,36 @@ function hasExistingSymlinkBetween(
|
||||
function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string, string>, runtime?: string, scope: string = 'global'): boolean {
|
||||
if (!saved || !saved.has('dev-preferences.md')) return false;
|
||||
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)
|
||||
// moves it entirely outside targetDir. Every confinement/guard check below
|
||||
// must confine against installRoot, not targetDir, or it would flag the
|
||||
// legitimate override destination as an escape.
|
||||
let installRoot: string = targetDir;
|
||||
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)
|
||||
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath), stemName);
|
||||
// #2911: same destination-root defect as _copyStaged/applySurface — honor
|
||||
// skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
|
||||
// -> $HOME/.agents) instead of always resolving against targetDir, so a
|
||||
// legacy dev-preferences migration lands in the SAME tree the installer
|
||||
// and surface-apply use. Runtimes with no `home` override are unaffected.
|
||||
installRoot = skillsKindEntry.home ?? targetDir;
|
||||
skillDir = path.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath), stemName);
|
||||
} else {
|
||||
// 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 (fs.existsSync(skillFile)) return false;
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// skillDir is a symlink that would redirect writes outside the config root.
|
||||
// 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.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
throw new Error(
|
||||
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
||||
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" 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.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -920,13 +932,20 @@ function installOpencodeFamilySkills(
|
||||
);
|
||||
}
|
||||
|
||||
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
||||
// Symlink-escape guard: reject if any path component between targetDir and
|
||||
// dest is a symlink that would redirect writes outside the config root.
|
||||
// #2911: same destination-root defect as _copyStaged/migrateLegacyDevPreferencesToSkill
|
||||
// — honor skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
|
||||
// -> $HOME/.agents) instead of always resolving against targetDir, so this bespoke
|
||||
// OpenCode/Kilo writer lands in the SAME tree the installer and surface-apply use.
|
||||
// Runtimes with no `home` override (opencode, kilo today) are unaffected. Must stay
|
||||
// in lockstep with the sibling writers — the destination-parity test enforces it.
|
||||
const installRoot: string = skillsKindEntry.home ?? targetDir;
|
||||
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, skillsKindEntry.destSubpath);
|
||||
// Symlink-escape guard: reject if any path component between installRoot and
|
||||
// dest is a symlink that would redirect writes outside the install root.
|
||||
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
||||
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
||||
throw new Error(
|
||||
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
|
||||
`installOpencodeFamilySkills: 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.`,
|
||||
);
|
||||
}
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
@@ -72,6 +72,10 @@ interface ArtifactKind {
|
||||
kind: string;
|
||||
destSubpath: string;
|
||||
prefix: string;
|
||||
// #2911: optional install-root override (e.g. Codex skills -> $HOME/.agents),
|
||||
// set by resolveRuntimeArtifactLayout's dispatchKindEntry. Must be honored as
|
||||
// a FALLBACK by every destination-computation call site — see applySurface.
|
||||
home?: string;
|
||||
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }, agentCtx?: AgentCtx) => string;
|
||||
}
|
||||
|
||||
@@ -415,7 +419,13 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
||||
tempDirsToClean.push(rewritten);
|
||||
}
|
||||
}
|
||||
const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
|
||||
// #2911: honor kind.home as a FALLBACK-preferred override (e.g. Codex
|
||||
// skills -> $HOME/.agents), never a blanket replacement — kinds without
|
||||
// a `home` must keep resolving against layout.configDir. This must stay
|
||||
// in lockstep with _copyStaged's root selection in src/install-engine.cts;
|
||||
// the parity test in tests/runtime-artifact-layout-surface.test.cjs
|
||||
// enforces that the two writers never diverge again.
|
||||
const dest = assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath);
|
||||
_syncGsdDir(staged, dest, kind, skillManifest, layout.runtime);
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user