Repository navigation
Conversation
A symlinked destination that the install root does not trust was refused only after copyWithPathReplacement had already removed and re-copied gsd-core/, so a refused upgrade left no VERSION, no .gsd-runtime and no file manifest, and the opted-in rerun backed up the first run's files as local patches. install() now calls preflightInstallSymlinkDestinations before its first write. The preflight runs the same hasExistingSymlinkBetween checks, with the same GSD_ALLOW_SYMLINKED_DEST opt-in and the same refusal messages, for gsd-core/, every artifact-layout kind against its own install root (an alternate kind.home included), the Runtime Surface corpus, and the Codex config.toml, agents/ and per-agent toml paths. The refusal strings and the install-root, destination and branch predicates move into runtime-artifact-install-plan so the preflight and the write-time guards share one copy. The write-time guards all stay in place. Tests: four regression tests in tests/install-runtime-artifacts.test.cjs. On upstream/next the preservation and alternate-home tests fail and the refusal and opt-in tests pass; with the fix all four pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bshiggins
requested review from
Solvely-Colin,
davesienkowski,
jeremymcs and
trek-e
as code owners
October 10, 2026 20:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix PR
Linked Issue
Fixes #5179
What was broken
When an artifact destination such as
~/.claude/skillssits behind a symlink the install root does not trust, the installer refused it only insideinstallRuntimeArtifacts(src/install-engine.cts:1313onnextat 651bd2f). By thencopyWithPathReplacementhad already removed and re-copiedgsd-core/(thermSyncatbin/install.js:7831, reached from 11356 and 11359). A refused run exited 1 and left a half-replaced install: noVERSION,.gsd-runtimeorgsd-file-manifest.json, and a newgsd-local-patches/backup. The rerun withGSD_ALLOW_SYMLINKED_DEST=1then reported nearly every file as a local modification.What this fix does
install()now callspreflightInstallSymlinkDestinationsbefore orphan recovery, local patch preservation, migrations and thegsd-core/replacement, so a refusal happens before the install's first write. In one pass it runs the samehasExistingSymlinkBetweentest, withallowOptInFollow: isSymlinkedDestOptIn(), on every destination the install will write:gsd-core/, against the config dir;kind.homewhen set, else the config dir);gsd-core/commands/gsd,gsd-core/agents);config.toml,agents/and each selected agent's.toml.Each refusal throws the message the later check for that destination throws today. Those messages now come from builders in
src/runtime-artifact-install-plan.ctsthat the preflight and the existing checks both call, so the text cannot drift. The preflight also takes each decision from a helper the install path itself calls: the install root and destination of a kind, the combined skill family, legacy flat local installs, standalone agents, the required corpus sources, whether Codex agent config is written, and the Codex agent.tomlnames. It therefore checks only destinations that install writes for that runtime, scope and mode, and refuses only where a later check would.Every existing check stays where it is.
hasExistingSymlinkBetweenandisSymlinkedDestOptInare unchanged, symlinks stay untrusted by default, andcopyWithPathReplacement's confinement check and thesettings.jsonpath (#5037) are untouched. Two checks are not moved, on purpose: the user-artifact staging root, because recovery and staging already warn and skip on an untrusted staging root instead of aborting, and the compatibility marker, whose writer is non-fatal.Root cause
As the triage found: the clean-install
rmSync(acd62c0) predates the opt-in refusal added in #2393 (12e4d93), which #2875 (3ab0007) kept inside the per-kind loop after thegsd-core/copy. The refusal therefore ran after the first destructive write.Testing
How I verified the fix
Four tests in
tests/install-runtime-artifacts.test.cjs, beside the other installer tests:refuses an untrusted symlinked destination: without the opt-in, a symlinkedskills/still exits non-zero with the existing message.leaves the previous install byte-identical: after that refusal, every path and hash under the config dir and the symlink target is unchanged, includingVERSION,.gsd-runtime,gsd-file-manifest.jsonand a sentinel file, and nogsd-local-patches/appears.installs the same layout with GSD_ALLOW_SYMLINKED_DEST=1: the opted-in run exits 0 and writes through the symlink.checks a kind with an alternate home against that home: a Codex global install puts skills under$HOME/.agents/skills; with that directory behind an untrusted symlink and the install seeded as an older one (VERSIONand a sentinel file), the rerun is refused with the same message, and the config dir,$HOME/.agentsand the symlink target are unchanged. It passes--no-legacy-cleanuponly to skip the optional legacy artifact scan, which runs after the install's writes.With
bin/install.jsandsrc/reverted tonext(651bd2f), the refusal and opt-in tests still pass, and the preservation and alternate-home tests fail. With the fix, all four pass, as do all 410 tests in that file.Gates, run locally on Node 24 against
nextat 651bd2f:build:lib,lint:ci,check:phase-id-drift,lint:changeset,lint:docs, the context-index check,prompt-injection-scan.sh --diff upstream/next, and the full suite (28 chunks, 44,415 tests, 44,377 passed, 1 failed). The one failure,#4988: installed local CLI syncs and reads its own Claude agentsintests/effort-local-install.test.cjs, fails the same way onnextitself on macOS: it compares the unresolved temp dir (/var/...) with the CLI's resolved path (/private/var/...). It is unrelated to this change.Regression test added?
Platforms tested
Runtimes tested
Checklist
Fixes #5179confirmed-buglabelnpm test).changeset/fragment added (agile-mice-jump.md, type Fixed)Breaking changes
None
🤖 Generated with Claude Code