fix(#2911): resolve surface re-stage destinations the way the installer does (#3049)

* 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:
Tom Boucher
2026-08-04 14:31:07 -04:00
committed by GitHub
parent f4185554ea
commit 8c1962200d
5 changed files with 424 additions and 11 deletions

View File

@@ -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 });

View File

@@ -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 {