@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -14,6 +14,9 @@ const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const node_path_1 = __importDefault(require("node:path"));
15
15
  const node_os_1 = __importDefault(require("node:os"));
16
16
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
17
+ // #2322: reuse the existing pure path-containment seam (ADR-1239 Phase C-2)
18
+ // instead of hand-rolling a new traversal check for capability skill stems.
19
+ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
17
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
21
  const conversionModule = require("./runtime-artifact-conversion.cjs");
19
22
  const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, } = conversionModule;
@@ -438,7 +441,182 @@ function transformRouterBodyToNested(converted) {
438
441
  });
439
442
  return out.join('\n');
440
443
  }
441
- function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix, nested = false) {
444
+ /**
445
+ * #2322 SECURITY: a third-party `capability.json`'s `skills[]` entries are only
446
+ * validated for being STRINGS and not one of the 3 reserved prototype-pollution
447
+ * names (capability-validator.cjs validateFeatureBody, ~line 503) — NOT for
448
+ * non-emptiness and NOT for a safe path-segment shape. `isSafeCapabilitySkillStem`
449
+ * is therefore the SOLE defense against an empty-string, `..`-escaping,
450
+ * separator-carrying, absolute, or NUL-carrying stem reaching a filesystem path
451
+ * as a literal component — not a second defense-in-depth layer on top of any
452
+ * validator-enforced non-emptiness (there is none). Once unioned into
453
+ * resolveSurface's `resolved.skills` (#2045), such a stem must never reach
454
+ * fs.readFileSync/writeFileSync as a literal path component, or it can escape
455
+ * the capabilities root on read (or stageDir on write). Reject anything but a
456
+ * single, ordinary path segment.
457
+ */
458
+ function isSafeCapabilitySkillStem(stem) {
459
+ if (typeof stem !== 'string' || stem.length === 0)
460
+ return false;
461
+ if (stem.includes('\0'))
462
+ return false;
463
+ if (stem === '.' || stem === '..')
464
+ return false;
465
+ if (stem.includes('/') || stem.includes('\\'))
466
+ return false;
467
+ if (node_path_1.default.isAbsolute(stem))
468
+ return false;
469
+ return true;
470
+ }
471
+ /**
472
+ * Resolve which capability id DECLARES ownership of `stem`, per the registry's
473
+ * `capabilityClusters` view (capId -> [owned skill stems]) — the SAME
474
+ * authoritative binding `_capabilitySkillsForMode` (above) and `resolveSurface`
475
+ * (surface.cts) already trust to decide which stems a capability contributes.
476
+ * `capabilityClusters` is derived (gen-capability-registry.cjs
477
+ * deriveCapabilityClusters) straight from each ACCEPTED capability's OWN
478
+ * declared, non-empty `skills[]` array — an UNDECLARED directory a capability
479
+ * happens to ship on disk (an unlisted `skills/<stem>/` bundled by mistake, or
480
+ * by a malicious author trying to hijack another capability's stem) never
481
+ * appears here, so it can never resolve as an owner. Two capabilities can never
482
+ * both own the same stem: the registry loader (capability-loader.cts) rejects a
483
+ * candidate whose declared skill collides with an already-registered owner
484
+ * BEFORE it is ever composed into the registry — so this lookup is unambiguous
485
+ * by construction. Returns null for an unowned/unregistered stem or a
486
+ * malformed registry (never throws).
487
+ */
488
+ function _owningCapabilityId(stem, clusters) {
489
+ const BANNED = ['__proto__', 'constructor', 'prototype'];
490
+ for (const capId of Object.keys(clusters)) {
491
+ if (BANNED.includes(capId))
492
+ continue;
493
+ const owned = clusters[capId];
494
+ if (!Array.isArray(owned))
495
+ continue;
496
+ if (owned.includes(stem))
497
+ return capId;
498
+ }
499
+ return null;
500
+ }
501
+ /**
502
+ * Union every stem ANY accepted capability declares across the WHOLE registry
503
+ * (unfiltered by mode/tier) — used only for the `'*'` (full profile) staging
504
+ * fill-in below, mirroring the SAME unconditional union `resolveSurface`
505
+ * (surface.cts) already performs when ITS OWN base profile resolves to `'*'`.
506
+ * Guards against a malformed/prototype-polluted registry; never throws.
507
+ */
508
+ function capabilityClusterStems(registry) {
509
+ const result = new Set();
510
+ const clusters = registry?.capabilityClusters;
511
+ if (!clusters || typeof clusters !== 'object')
512
+ return result;
513
+ const BANNED = ['__proto__', 'constructor', 'prototype'];
514
+ for (const capId of Object.keys(clusters)) {
515
+ if (BANNED.includes(capId))
516
+ continue;
517
+ const stems = clusters[capId];
518
+ if (!Array.isArray(stems))
519
+ continue;
520
+ for (const s of stems) {
521
+ if (typeof s === 'string' && s.length > 0)
522
+ result.add(s);
523
+ }
524
+ }
525
+ return result;
526
+ }
527
+ /**
528
+ * #2322 HIGH-3: filesystem marker written into every staged THIRD-PARTY
529
+ * capability skill directory (alongside SKILL.md) so a later prune pass
530
+ * (surface.cts pruneSkillDirs) can identify the directory as GSD-capability-
531
+ * owned even after the owning capability has been uninstalled/unsurfaced and
532
+ * no longer appears in ANY registry view. Without a persisted marker, an
533
+ * orphaned capability skill directory has no first-party manifest entry (the
534
+ * skill manifest only ever knows gsd-core's own bundled stems) and
535
+ * pruneSkillDirs' conservative unknown-directory branch would preserve it
536
+ * FOREVER — uninstalling a malicious capability would never actually remove
537
+ * its already-staged instructions from the agent's context. A directory
538
+ * WITHOUT this marker is presumed genuinely user-created (data-loss
539
+ * protection is unchanged for that case).
540
+ */
541
+ const CAPABILITY_SKILL_MARKER = '.gsd-capability-skill';
542
+ /**
543
+ * Look up an installed third-party capability's already-authored SKILL.md for
544
+ * `stem`, bound to its DECLARING capability via the registry's
545
+ * `capabilityClusters` view (capId -> owned stems) — NEVER by scanning every
546
+ * installed capability directory and taking the first (sorted) match.
547
+ *
548
+ * #2322 BLOCKER 1: the prior implementation scanned every directory under the
549
+ * capabilities root for a `skills/<stem>/SKILL.md` file and returned the FIRST
550
+ * SORTED match, regardless of whether that capability actually DECLARED the
551
+ * stem in its `capability.json` `skills[]` and regardless of whether it was
552
+ * the (sole) REGISTERED owner. An attacker-controlled capability could ship an
553
+ * UNDECLARED `skills/<victim-stem>/SKILL.md` directory that sorted ahead of
554
+ * the legitimate, declaring capability and hijack its stem — the agent would
555
+ * load the attacker's instructions believing they came from the legitimate
556
+ * capability. Resolving `stem -> capId` via `capabilityClusters` FIRST (the
557
+ * same authoritative binding `resolveSurface`/`_capabilitySkillsForMode`
558
+ * trust) then reading ONLY that capability's own directory makes an
559
+ * undeclared/unregistered sibling directory unreachable by construction.
560
+ *
561
+ * The install-root path convention (`<capabilitiesRoot>/<capId>/skills/<stem>/
562
+ * SKILL.md` under `GSD_HOME || homedir()`) mirrors capability-loader.cts
563
+ * (global overlay root) and capability-source.cts's `stageValidated` finalDir.
564
+ *
565
+ * Total/non-throwing (#2322 requirement 5): no registry, an unowned stem, a
566
+ * missing capabilities root, an unreadable capability dir, or a missing/
567
+ * corrupt SKILL.md all degrade to `null` (skip that stem) rather than
568
+ * throwing — a partial/corrupt third-party install must never break
569
+ * first-party staging. No registry at all means NOTHING third-party is
570
+ * staged (fail closed — never a fallback scan).
571
+ *
572
+ * NOTE: the content returned here is staged AS-IS (no per-file `converter`
573
+ * runs on it — unlike gsd-core's flat command `.md`, an installed capability
574
+ * skill is already a complete SKILL.md), but it is NOT immune from the LATER
575
+ * runtime-targeted body rewrite pass `applySurface` runs over the ENTIRE
576
+ * staged directory (`rewriteStagedSkillBodies`, surface.cts): a `~/.claude/`
577
+ * (etc.) path reference in a third-party skill body IS rewritten exactly like
578
+ * a first-party one. "As-is" here refers only to this copy step, not to the
579
+ * final on-disk content after a full `applySurface` run.
580
+ */
581
+ function readInstalledCapabilitySkill(stem, registry) {
582
+ if (!isSafeCapabilitySkillStem(stem))
583
+ return null;
584
+ if (!registry || !registry.capabilityClusters || typeof registry.capabilityClusters !== 'object')
585
+ return null;
586
+ const capId = _owningCapabilityId(stem, registry.capabilityClusters);
587
+ if (capId === null)
588
+ return null;
589
+ // Defense-in-depth: capId is a real accepted-capability directory name (a
590
+ // trusted fs.readdirSync entry at capability-loader.cts accept time), but
591
+ // re-validate its path-segment shape before using it as a literal path
592
+ // component in case a future registry composer ever stops guaranteeing that.
593
+ if (!isSafeCapabilitySkillStem(capId))
594
+ return null;
595
+ const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
596
+ const capDir = node_path_1.default.join(home, '.gsd', 'capabilities', capId);
597
+ const relSkillPath = node_path_1.default.join('skills', stem, 'SKILL.md');
598
+ // Defense-in-depth: isSafeCapabilitySkillStem already rejects separators/
599
+ // '..'/absolute stems, but re-confirm the resolved read path stays under
600
+ // this capability's own directory before ever touching the filesystem.
601
+ if (!(0, external_descriptor_trust_cjs_1.isPathConfined)(relSkillPath, capDir))
602
+ return null;
603
+ const skillPath = node_path_1.default.join(capDir, relSkillPath);
604
+ try {
605
+ if (!node_fs_1.default.statSync(skillPath).isFile())
606
+ return null;
607
+ return { capId, content: node_fs_1.default.readFileSync(skillPath, 'utf8') };
608
+ }
609
+ catch {
610
+ return null; // missing / unreadable / corrupt entry -> skip
611
+ }
612
+ }
613
+ /**
614
+ * @param registry optional capability registry (capabilityClusters view) —
615
+ * when present, third-party capability skills are unioned into the staged
616
+ * output (bound to their declaring capId; see readInstalledCapabilitySkill).
617
+ * When absent, NOTHING third-party is staged (fail closed).
618
+ */
619
+ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix, nested = false, registry) {
442
620
  if (!node_fs_1.default.existsSync(srcCommandsDir))
443
621
  return srcCommandsDir;
444
622
  // Nesting applies to the `full` install AND to any surface whose skill set
@@ -458,6 +636,10 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
458
636
  doNest = [...bundles.routerStems].every((r) => present.has(r));
459
637
  }
460
638
  }
639
+ // #2322: stems actually staged from gsd-core's OWN bundled commands/gsd dir
640
+ // this call, so the third-party fill-in pass below can enforce "first-party
641
+ // ALWAYS wins on collision" without re-deriving membership.
642
+ const firstPartyStems = new Set();
461
643
  const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-skills-'));
462
644
  try {
463
645
  const entries = node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true });
@@ -469,6 +651,7 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
469
651
  const stem = entry.name.slice(0, -3);
470
652
  if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem))
471
653
  continue;
654
+ firstPartyStems.add(stem);
472
655
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8');
473
656
  const skillName = `${prefix}${stem}`;
474
657
  const converted = converter(content, skillName);
@@ -497,6 +680,55 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
497
680
  node_fs_1.default.mkdirSync(destDir, { recursive: true });
498
681
  node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), converted);
499
682
  }
683
+ // #2322: materialize installed THIRD-PARTY capability skills, bound to
684
+ // their DECLARING capability via the registry's capabilityClusters view
685
+ // (see readInstalledCapabilitySkill — NEVER scan-and-first-match). The
686
+ // registry union (#2045) already puts every accepted-capability stem into
687
+ // a concrete resolvedProfile.skills Set, but srcCommandsDir only ever
688
+ // holds gsd-core's own bundled commands — so any stem with no first-party
689
+ // file here was silently dropped (registry says surfaced:true, nothing on
690
+ // disk) unless we fill it in from the capability's own install dir.
691
+ //
692
+ // BLOCKER 2 (#2322): `resolveProfile` short-circuits the `full` profile
693
+ // straight to the `'*'` sentinel BEFORE ever consulting a registry — the
694
+ // sentinel therefore carries no per-stem list of its own, and a bare
695
+ // `resolvedProfile.skills !== '*'` gate here skipped this ENTIRE fill-in
696
+ // pass for a `full` install regardless of what the registry declared
697
+ // (the issue's default-profile repro: `mode=full` staged zero third-party
698
+ // skills even when `mode=standard` on the SAME registry staged them
699
+ // correctly). When `resolvedProfile.skills === '*'`, the candidate stems
700
+ // are instead every stem the registry's `capabilityClusters` declares —
701
+ // mirroring the SAME unconditional union `resolveSurface` (surface.cts,
702
+ // "Issue #2045" block) already performs for its own `'*'` case. When
703
+ // `resolvedProfile.skills` is a concrete Set, the candidate stems are the
704
+ // ones `_capabilitySkillsForMode` already unioned into it (unchanged).
705
+ //
706
+ // No registry in scope at all -> stage NOTHING third-party (fail closed —
707
+ // never fall back to scanning). Nesting (#69) never applies to a
708
+ // capability skill — it was never a child of any ns-* router's
709
+ // `requires:` list — so it always lands flat at the top level, exactly
710
+ // like an unrouted first-party skill.
711
+ if (registry) {
712
+ const candidateStems = resolvedProfile.skills === '*' ? capabilityClusterStems(registry) : resolvedProfile.skills;
713
+ for (const stem of candidateStems) {
714
+ if (firstPartyStems.has(stem))
715
+ continue; // first-party always wins
716
+ const found = readInstalledCapabilitySkill(stem, registry);
717
+ if (found === null)
718
+ continue; // absent/malformed/unowned -> skip gracefully
719
+ const skillName = `${prefix}${stem}`;
720
+ if (!(0, external_descriptor_trust_cjs_1.isPathConfined)(skillName, stageDir))
721
+ continue; // defense-in-depth
722
+ const destDir = node_path_1.default.join(stageDir, skillName);
723
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
724
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), found.content);
725
+ // #2322 HIGH-3: persist the capability-owned marker so a later prune
726
+ // pass (surface.cts pruneSkillDirs) can identify — and remove — this
727
+ // directory even once the owning capability is uninstalled/unsurfaced
728
+ // and no longer appears in any registry view.
729
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
730
+ }
731
+ }
500
732
  }
501
733
  catch (err) {
502
734
  try {
@@ -822,6 +1054,12 @@ module.exports = {
822
1054
  parseRequires,
823
1055
  parseCallsAgents,
824
1056
  cleanupStagedSkills,
1057
+ // #2322: capability-skill security seams — exported for direct unit-testing
1058
+ // and for surface.cts's prune pass (CAPABILITY_SKILL_MARKER parity).
1059
+ isSafeCapabilitySkillStem,
1060
+ readInstalledCapabilitySkill,
1061
+ capabilityClusterStems,
1062
+ CAPABILITY_SKILL_MARKER,
825
1063
  // Back-compat / deprecated
826
1064
  MINIMAL_SKILL_ALLOWLIST,
827
1065
  isMinimalMode,
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.validateInstallerMigrationRecord = validateInstallerMigrationRecord;
15
15
  exports.validateInstallerMigrationActions = validateInstallerMigrationActions;
16
16
  const node_path_1 = __importDefault(require("node:path"));
17
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
17
18
  function getStr(record, field) {
18
19
  const v = record[field];
19
20
  return typeof v === 'string' ? v : '';
@@ -56,7 +57,7 @@ function requireActionEvidence(action, field, migration) {
56
57
  }
57
58
  function validateSafeRelPath(relPath, migration, actionType) {
58
59
  const source = actionSource(migration, { relPath });
59
- const normalized = relPath.replace(/\\/g, '/');
60
+ const normalized = (0, shell_command_projection_cjs_1.posixNormalize)(relPath);
60
61
  if (node_path_1.default.isAbsolute(normalized) || node_path_1.default.win32.isAbsolute(normalized)) {
61
62
  throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
62
63
  }
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ /**
3
+ * Installer migration 005: baseline pre-existing OpenCode commands/ (plural)
4
+ * files during the first-time installer migration baseline scan (#2329
5
+ * follow-up).
6
+ *
7
+ * Background: #2329 moved OpenCode's slash-command install target from the
8
+ * legacy singular `command/` alias to the documented plural `commands/`
9
+ * convention. The first-time baseline scan migration
10
+ * (2026-05-11-first-time-baseline-scan, 000-first-time-baseline.cts) is a
11
+ * SHIPPED migration body — docs/installer-migrations.md#state-files requires
12
+ * shipped migration bodies stay immutable so an already-applied migration's
13
+ * checksum never drifts for a user who ran it before this fix (issue #670).
14
+ * Its RUNTIME_SURFACES.opencode list still only names the legacy `command`
15
+ * directory, so the baseline scan never classified pre-existing files under
16
+ * `commands/` before this fix.
17
+ *
18
+ * Consequence proven by probe (see tests/installer-migrations.test.cjs): a
19
+ * pre-existing, unmanifested file at `commands/gsd-<name>.md` that predates
20
+ * any GSD install is silently destroyed by ordinary OpenCode command
21
+ * materialization (which unconditionally removes every `gsd-*.md` file under
22
+ * its destination before writing the fresh set) with a clean exit code — no
23
+ * report, no backup, no prompt. The identical scenario under the
24
+ * already-covered legacy `command/` surface instead halts the install with a
25
+ * blocked `prompt-user` action, exactly as designed. This migration closes
26
+ * that gap for `commands/` without editing 000's shipped body.
27
+ *
28
+ * This is a NEW fix-forward migration id (per
29
+ * docs/installer-migrations.md#state-files) rather than an edit to 000: an
30
+ * already-applied migration never re-runs, so editing 000 would only protect
31
+ * fresh installs and leave every machine that already applied 000
32
+ * permanently unprotected for `commands/`. A new id runs for both
33
+ * populations (installs that never ran a baseline scan AND installs that
34
+ * already applied the original 000 scan) and drifts no checksum.
35
+ *
36
+ * Scope: OpenCode only. Kilo shares the same combined-family install path,
37
+ * but its command directory descriptor is still the singular `command/`
38
+ * (already covered by 000's RUNTIME_SURFACES.kilo), so this migration must
39
+ * never touch Kilo installs — enforced by the `runtimes: ['opencode']`
40
+ * scoping below.
41
+ *
42
+ * Classification mirrors 000-first-time-baseline.cts exactly (record-baseline
43
+ * for manifest-proven files, prompt-user for stale-GSD-looking unmanifested
44
+ * files, baseline-preserve-user for everything else) — this migration only
45
+ * widens the surface scanned, not the classification policy.
46
+ */
47
+ var __importDefault = (this && this.__importDefault) || function (mod) {
48
+ return (mod && mod.__esModule) ? mod : { "default": mod };
49
+ };
50
+ const node_fs_1 = __importDefault(require("node:fs"));
51
+ const node_path_1 = __importDefault(require("node:path"));
52
+ const SURFACE = 'commands';
53
+ function normalizeRelPath(relPath) {
54
+ return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
55
+ }
56
+ function walkFiles(root, relDir, files) {
57
+ const dir = node_path_1.default.join(root, relDir);
58
+ if (!node_fs_1.default.existsSync(dir))
59
+ return;
60
+ const entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
61
+ for (const entry of entries) {
62
+ const relPath = node_path_1.default.posix.join(relDir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ walkFiles(root, relPath, files);
65
+ }
66
+ else if (entry.isFile()) {
67
+ files.add(normalizeRelPath(relPath));
68
+ }
69
+ }
70
+ }
71
+ function scanCommandsSurface(configDir) {
72
+ const relPaths = new Set();
73
+ const fullPath = node_path_1.default.join(configDir, SURFACE);
74
+ if (!node_fs_1.default.existsSync(fullPath))
75
+ return [];
76
+ const stat = node_fs_1.default.statSync(fullPath);
77
+ if (stat.isDirectory()) {
78
+ walkFiles(configDir, SURFACE, relPaths);
79
+ }
80
+ else if (stat.isFile()) {
81
+ relPaths.add(SURFACE);
82
+ }
83
+ return [...relPaths];
84
+ }
85
+ function isStaleGsdLookingPath(relPath) {
86
+ return /^gsd[-_]/.test(node_path_1.default.posix.basename(relPath));
87
+ }
88
+ function baselineActionRank(action) {
89
+ if (action.type === 'record-baseline')
90
+ return 0;
91
+ if (action.type === 'baseline-preserve-user')
92
+ return 1;
93
+ return 2;
94
+ }
95
+ const migration = {
96
+ id: '2026-07-17-opencode-baseline-commands-dir',
97
+ title: "Baseline OpenCode's commands/ directory in the first-time scan (#2329 follow-up)",
98
+ description: "Classify pre-existing files under OpenCode's commands/ (plural) directory during the first-time installer " +
99
+ 'migration baseline scan. #2329 moved OpenCode command materialization from the legacy command/ alias to ' +
100
+ "commands/, but the shipped 000-first-time-baseline.cts RUNTIME_SURFACES.opencode list is immutable and still " +
101
+ 'only names command/; this fix-forward migration widens the scanned surface without editing the shipped body.',
102
+ introducedIn: '1.7.0',
103
+ runtimes: ['opencode'],
104
+ scopes: ['global', 'local'],
105
+ destructive: false,
106
+ plan: ({ configDir, baselineScan, classifyArtifact }) => {
107
+ if (!baselineScan)
108
+ return [];
109
+ const actions = [];
110
+ for (const relPath of scanCommandsSurface(configDir)) {
111
+ const artifact = classifyArtifact(relPath);
112
+ if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') {
113
+ actions.push({
114
+ type: 'record-baseline',
115
+ relPath,
116
+ reason: 'existing manifest-managed OpenCode commands/ file included in first-time migration baseline',
117
+ });
118
+ continue;
119
+ }
120
+ const currentHash = artifact.currentHash ?? null;
121
+ if (isStaleGsdLookingPath(relPath)) {
122
+ actions.push({
123
+ type: 'prompt-user',
124
+ relPath,
125
+ reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
126
+ classification: 'stale-gsd-looking',
127
+ originalHash: artifact.originalHash ?? null,
128
+ currentHash,
129
+ prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
130
+ choices: ['keep', 'remove'],
131
+ });
132
+ continue;
133
+ }
134
+ actions.push({
135
+ type: 'baseline-preserve-user',
136
+ relPath,
137
+ reason: 'unknown OpenCode commands/ file preserved by first-time migration baseline',
138
+ classification: artifact.classification,
139
+ originalHash: artifact.originalHash ?? null,
140
+ currentHash,
141
+ });
142
+ }
143
+ return actions.sort((left, right) => baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath));
144
+ },
145
+ };
146
+ module.exports = migration;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * Installer migration: retire pi's stale `extensions/gsd.cjs` after #2470
4
+ * renamed the installed native extension to `extensions/gsd.js`.
5
+ *
6
+ * What old artifact is being retired?
7
+ * `extensions/gsd.cjs` — the pre-#2470 dest filename for pi's native
8
+ * extension. pi auto-discovers extensions by scanning `<agentDir>/extensions/`
9
+ * and keeping only names accepted by its own predicate
10
+ * (`isExtensionFile()` in @earendil-works/pi-coding-agent:
11
+ * `name.endsWith(".ts") || name.endsWith(".js")`). A `.cjs` file is skipped
12
+ * SILENTLY — no `/gsd` command, no error, no log line. The file is therefore
13
+ * permanently inert, not merely redundant.
14
+ *
15
+ * How do we prove it is GSD-owned?
16
+ * The installer records the native plugin in the install manifest as
17
+ * `<nativePlugin.dir>/<nativePlugin.file>` (bin/install.js, the
18
+ * `_hostBehaviors(runtime).nativePlugin` manifest block), so a pre-#2470 pi
19
+ * install carries `extensions/gsd.cjs` as a manifest-managed entry. Only a
20
+ * manifest-managed classification produces an action here; an unmanifested
21
+ * `gsd.cjs` is treated as a user's own file and preserved.
22
+ *
23
+ * What happens if the user modified it?
24
+ * `backup-and-remove` instead of `remove-managed`, so a patched extension is
25
+ * recoverable from the backup rather than silently destroyed.
26
+ *
27
+ * What happens if it is missing?
28
+ * No actions — fresh (post-#2470) installs and already-migrated installs both
29
+ * plan empty, so the migration is idempotent.
30
+ *
31
+ * What runtime and scope does it affect?
32
+ * pi only, global and local. No other runtime ever installed this path:
33
+ * OpenCode and Kilo — the only other runtimes declaring
34
+ * `hostBehaviors.nativePlugin` — both ship `plugins/gsd-core.js`.
35
+ *
36
+ * Is the action safe in non-interactive install?
37
+ * Yes. Both emitted action types are non-interactive and journaled; neither
38
+ * requires a user choice, and unknown files never produce an action.
39
+ *
40
+ * Why not `move-managed`? The installer materializes the new `extensions/gsd.js`
41
+ * from the package payload in the same run, so moving the stale file onto that
42
+ * path would just be overwritten. Retiring the old path is the accurate
43
+ * description of the change.
44
+ *
45
+ * See docs/installer-migrations.md#shipped-migrations and the pi row of
46
+ * docs/installer-migrations.md#runtime-configuration-contract-registry.
47
+ */
48
+ /** Pre-#2470 dest filename for pi's native extension. */
49
+ const STALE_PI_EXTENSION = 'extensions/gsd.cjs';
50
+ const OWNERSHIP_EVIDENCE = 'pre-#2470 pi installs record the native extension at extensions/gsd.cjs in ' +
51
+ 'gsd-file-manifest.json (installer nativePlugin manifest entry)';
52
+ const REASON = 'pi cannot auto-discover a .cjs extension (isExtensionFile accepts only .ts/.js), ' +
53
+ 'so this file is inert; superseded by extensions/gsd.js (#2470)';
54
+ const migration = {
55
+ id: '2026-07-20-pi-extension-cjs-to-js',
56
+ title: 'Retire pi\'s undiscoverable extensions/gsd.cjs',
57
+ description: 'Remove the stale extensions/gsd.cjs left by pre-#2470 pi installs, superseded by ' +
58
+ 'extensions/gsd.js — the suffix pi\'s extension auto-discovery actually accepts.',
59
+ introducedIn: '1.7.1',
60
+ runtimes: ['pi'],
61
+ scopes: ['global', 'local'],
62
+ destructive: true,
63
+ plan: (ctx) => {
64
+ const artifact = ctx.classifyArtifact(STALE_PI_EXTENSION);
65
+ if (artifact.classification === 'managed-pristine') {
66
+ return [
67
+ {
68
+ type: 'remove-managed',
69
+ relPath: STALE_PI_EXTENSION,
70
+ reason: REASON,
71
+ ownershipEvidence: OWNERSHIP_EVIDENCE,
72
+ },
73
+ ];
74
+ }
75
+ if (artifact.classification === 'managed-modified') {
76
+ return [
77
+ {
78
+ type: 'backup-and-remove',
79
+ relPath: STALE_PI_EXTENSION,
80
+ reason: REASON,
81
+ ownershipEvidence: OWNERSHIP_EVIDENCE,
82
+ },
83
+ ];
84
+ }
85
+ // 'unknown' (never GSD-managed), 'missing', and 'managed-missing' all plan
86
+ // nothing: unknown files are preserved by policy, and an absent file needs
87
+ // no retirement.
88
+ return [];
89
+ },
90
+ };
91
+ module.exports = migration;
@@ -42,6 +42,37 @@ function sha256File(filePath) {
42
42
  function sha256Text(value) {
43
43
  return node_crypto_1.default.createHash('sha256').update(value).digest('hex');
44
44
  }
45
+ /**
46
+ * Copy a managed path for the rollback snapshot or the user-facing backup,
47
+ * WITHOUT dereferencing a symlink.
48
+ *
49
+ * `fs.copyFileSync` follows symlinks, so a managed path that has been replaced
50
+ * by a link (tampering, or an unexpected user layout) would have had the
51
+ * LINK TARGET's bytes copied into `gsd-migration-journal/…-backups/` — e.g. a
52
+ * `gsd.cjs` symlinked at `~/.ssh/id_rsa` would land that key's contents in the
53
+ * backup tree. Nothing GSD installs is ever a symlink, so the faithful snapshot
54
+ * of a symlinked managed path is the link itself: recreating it preserves
55
+ * rollback fidelity (restore re-creates the same link) while never reading the
56
+ * referent. Deletion was already safe — `fs.rmSync` unlinks the link, never the
57
+ * target.
58
+ *
59
+ * Windows note: `fs.symlinkSync` can throw EPERM for unprivileged users. That
60
+ * surfaces as an apply failure and triggers the normal rollback path, which is
61
+ * the correct outcome — refusing to proceed beats silently copying referent
62
+ * bytes.
63
+ */
64
+ function copyPreservingSymlink(srcPath, destPath) {
65
+ if (node_fs_1.default.lstatSync(srcPath).isSymbolicLink()) {
66
+ // symlinkSync fails with EEXIST on an occupied path, so clear it first.
67
+ // Scoped to this branch on purpose: the regular-file path below keeps
68
+ // copyFileSync's overwrite-in-place, so a mid-restore failure cannot leave
69
+ // the destination destroyed.
70
+ node_fs_1.default.rmSync(destPath, { force: true });
71
+ node_fs_1.default.symlinkSync(node_fs_1.default.readlinkSync(srcPath), destPath);
72
+ return;
73
+ }
74
+ node_fs_1.default.copyFileSync(srcPath, destPath);
75
+ }
45
76
  function readJsonIfPresent(filePath, fallback) {
46
77
  if (!node_fs_1.default.existsSync(filePath))
47
78
  return fallback;
@@ -118,7 +149,7 @@ function normalizeRelPath(relPath) {
118
149
  if (typeof relPath !== 'string' || relPath.trim() === '') {
119
150
  throw new Error('migration action relPath must be a non-empty string');
120
151
  }
121
- const normalized = relPath.replace(/\\/g, '/');
152
+ const normalized = (0, shell_command_projection_cjs_1.posixNormalize)(relPath);
122
153
  if (node_path_1.default.isAbsolute(normalized) || node_path_1.default.win32.isAbsolute(normalized)) {
123
154
  throw new Error(`migration action relPath must stay inside configDir: ${relPath}`);
124
155
  }
@@ -518,9 +549,12 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
518
549
  const rollbackPath = node_path_1.default.join(configDir, action.rollbackRelPath);
519
550
  const dest = node_path_1.default.join(configDir, action.relPath);
520
551
  try {
521
- if (node_fs_1.default.existsSync(rollbackPath)) {
552
+ // lstat-based existence check: a snapshot of a symlinked managed path is
553
+ // itself a link, and existsSync() follows it — a link whose target is
554
+ // gone would read as "missing" and silently skip the restore.
555
+ if (node_fs_1.default.lstatSync(rollbackPath, { throwIfNoEntry: false })) {
522
556
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
523
- node_fs_1.default.copyFileSync(rollbackPath, dest);
557
+ copyPreservingSymlink(rollbackPath, dest);
524
558
  }
525
559
  }
526
560
  catch (error) {
@@ -623,7 +657,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
623
657
  }
624
658
  const rollbackPath = node_path_1.default.join(rollbackRoot, normalized);
625
659
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(rollbackPath), { recursive: true });
626
- node_fs_1.default.copyFileSync(fullPath, rollbackPath);
660
+ copyPreservingSymlink(fullPath, rollbackPath);
627
661
  rollback.push({ relPath: normalized, rollbackPath });
628
662
  if (action.type === 'rewrite-json') {
629
663
  if (action.deleteIfEmpty && isStructurallyEmpty(action.value)) {
@@ -644,7 +678,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
644
678
  const backupRelPath = action.backupRelPath || node_path_1.default.posix.join(backupRootRelPath, normalized);
645
679
  const backupPath = node_path_1.default.join(configDir, backupRelPath);
646
680
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(backupPath), { recursive: true });
647
- node_fs_1.default.copyFileSync(fullPath, backupPath);
681
+ copyPreservingSymlink(fullPath, backupPath);
648
682
  journal.actions.push(journalAction(action, 'removed', {
649
683
  backupRelPath,
650
684
  rollbackRelPath: node_path_1.default.posix.join(rollbackRootRelPath, normalized),
@@ -695,7 +729,12 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
695
729
  const dest = node_path_1.default.join(configDir, entry.relPath);
696
730
  try {
697
731
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
698
- node_fs_1.default.copyFileSync(entry.rollbackPath, dest);
732
+ // Symlink-preserving, same as the forward path: `entry.rollbackPath` is
733
+ // itself a link whenever the managed path was one, so a raw copy here
734
+ // would dereference it and write the referent's bytes back to the LIVE
735
+ // install path — a worse leak than the journal-tree one, since it is
736
+ // user-visible and at a predictable location.
737
+ copyPreservingSymlink(entry.rollbackPath, dest);
699
738
  }
700
739
  catch (rollbackError) {
701
740
  rollbackFailures.push({