@opengsd/gsd-core 1.10.0 → 1.11.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +5 -5
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +37 -0
- package/agents/gsd-planner.md +44 -46
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +12 -3
- package/bin/install.js +841 -971
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +469 -88
- package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
- package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
- package/gsd-core/bin/lib/api-coverage.cjs +3 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +876 -240
- package/gsd-core/bin/lib/broken-windows.cjs +1 -1
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +575 -101
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +495 -22
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +71 -37
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +651 -86
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +75 -0
- package/gsd-core/bin/lib/config.cjs +10 -1
- package/gsd-core/bin/lib/core-utils.cjs +127 -29
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +155 -20
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +321 -129
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +745 -258
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +134 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
- package/gsd-core/bin/lib/io.cjs +10 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +754 -70
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +444 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +125 -18
- package/gsd-core/bin/lib/phase.cjs +646 -143
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +385 -94
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
- package/gsd-core/bin/lib/smart-entry.cjs +142 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +371 -117
- package/gsd-core/bin/lib/state.cjs +1794 -357
- package/gsd-core/bin/lib/surface.cjs +23 -9
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
- package/gsd-core/bin/lib/uat.cjs +399 -56
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +24 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +258 -8
- package/gsd-core/bin/lib/verify.cjs +368 -888
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +38 -12
- package/gsd-core/workflows/complete-milestone.md +141 -18
- package/gsd-core/workflows/debug.md +7 -5
- package/gsd-core/workflows/diagnose-issues.md +35 -9
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
- package/gsd-core/workflows/execute-phase.md +38 -50
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +5 -5
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone.md +14 -9
- package/gsd-core/workflows/new-project.md +14 -14
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase.md +41 -17
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress.md +34 -6
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
- package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
- package/gsd-core/workflows/quick.md +35 -15
- package/gsd-core/workflows/review.md +26 -5
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/spec-phase.md +30 -12
- package/gsd-core/workflows/sync-skills.md +63 -8
- package/gsd-core/workflows/transition.md +46 -11
- package/gsd-core/workflows/ui-phase.md +5 -5
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update.md +1 -1
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +9 -7
- package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +38 -24
- package/hooks/dist/gsd-statusline.js +18 -0
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/gsd-agent-isolation-guard.js +103 -14
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +77 -2
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +38 -24
- package/hooks/gsd-statusline.js +18 -0
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/package.json +21 -9
- package/pi/gsd.cjs +19 -5
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +21 -10
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +25 -0
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -32,6 +32,24 @@ const retiredArtifactCleanup = require("./retired-artifact-cleanup.cjs");
|
|
|
32
32
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
33
33
|
const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs");
|
|
34
34
|
const commonjs_marker_cjs_1 = require("./commonjs-marker.cjs");
|
|
35
|
+
// #2874 (ADR-58 cleanup phase): the injectable fs seam for the
|
|
36
|
+
// installRuntimeArtifacts call tree. `installFs()` resolves to real
|
|
37
|
+
// `node:fs` unless a call is wrapped in `withInstallFs(deps.fs, ...)` —
|
|
38
|
+
// every fs call below in this file that installRuntimeArtifacts's own call
|
|
39
|
+
// tree reaches goes through it. See install-fs-adapter.cts's module doc for
|
|
40
|
+
// why this is an ambient swap rather than a threaded `deps` parameter.
|
|
41
|
+
const installFsAdapter = require("./install-fs-adapter.cjs");
|
|
42
|
+
const { installFs, withInstallFs } = installFsAdapter;
|
|
43
|
+
// #2875 (epic #2866 Phase 6): durable on-disk staging for USER_OWNED_ARTIFACTS
|
|
44
|
+
// across the preserve -> wipe -> restore window (#1874-F19). See
|
|
45
|
+
// user-artifact-staging.cts's module doc.
|
|
46
|
+
const userArtifactStaging = require("./user-artifact-staging.cjs");
|
|
47
|
+
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
|
|
48
|
+
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
|
|
49
|
+
// this module's two remaining re-derivation sites need (see the
|
|
50
|
+
// module-level doc comment on `isGlobalScope` for why the projection is
|
|
51
|
+
// centralized rather than eliminated).
|
|
52
|
+
const install_scope_cjs_1 = require("./install-scope.cjs");
|
|
35
53
|
const { processAttribution } = runtimeArtifactConversion;
|
|
36
54
|
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
|
37
55
|
// test stubs that monkeypatch the module's exports are seen at call time.
|
|
@@ -50,8 +68,9 @@ const { getDirName } = runtimeNamePolicy;
|
|
|
50
68
|
*
|
|
51
69
|
* Invariant: a file is either distribution (manifest-tracked, diff'd against
|
|
52
70
|
* manifest) or user artifact (preserved across installs, never diff'd). Never
|
|
53
|
-
* both. Both
|
|
54
|
-
* this list, which is why it lives here as a
|
|
71
|
+
* both. Both the user-artifact-staging.cts call sites (#2875) and
|
|
72
|
+
* writeManifest must agree on this list, which is why it lives here as a
|
|
73
|
+
* single constant.
|
|
55
74
|
*
|
|
56
75
|
* Paths are relative to the gsd-core/ directory.
|
|
57
76
|
*/
|
|
@@ -120,45 +139,6 @@ const SKILLS_CONVERTER_REGISTRY = {
|
|
|
120
139
|
convertClaudeCommandToKimiCodeSkill: runtimeArtifactConversion.convertClaudeCommandToKimiCodeSkill,
|
|
121
140
|
};
|
|
122
141
|
// ---------------------------------------------------------------------------
|
|
123
|
-
// User-artifact preservation helpers
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
/**
|
|
126
|
-
* Save user-generated files from destDir to an in-memory map before a wipe.
|
|
127
|
-
*
|
|
128
|
-
* @param destDir - Directory that is about to be wiped
|
|
129
|
-
* @param fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
|
|
130
|
-
* @returns Map of fileName → file content (only entries that existed)
|
|
131
|
-
*/
|
|
132
|
-
function preserveUserArtifacts(destDir, fileNames) {
|
|
133
|
-
const saved = new Map();
|
|
134
|
-
for (const name of fileNames) {
|
|
135
|
-
const fullPath = node_path_1.default.join(destDir, name);
|
|
136
|
-
if (node_fs_1.default.existsSync(fullPath)) {
|
|
137
|
-
try {
|
|
138
|
-
saved.set(name, node_fs_1.default.readFileSync(fullPath, 'utf8'));
|
|
139
|
-
}
|
|
140
|
-
catch { /* skip unreadable files */ }
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return saved;
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Restore user-generated files saved by preserveUserArtifacts after a wipe.
|
|
147
|
-
*
|
|
148
|
-
* @param destDir - Directory that was wiped and recreated
|
|
149
|
-
* @param saved - Map returned by preserveUserArtifacts
|
|
150
|
-
*/
|
|
151
|
-
function restoreUserArtifacts(destDir, saved) {
|
|
152
|
-
for (const [name, content] of saved) {
|
|
153
|
-
const fullPath = node_path_1.default.join(destDir, name);
|
|
154
|
-
try {
|
|
155
|
-
node_fs_1.default.mkdirSync(node_path_1.default.dirname(fullPath), { recursive: true });
|
|
156
|
-
node_fs_1.default.writeFileSync(fullPath, content, 'utf8');
|
|
157
|
-
}
|
|
158
|
-
catch { /* skip unwritable paths */ }
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
142
|
// Symlink-escape guard
|
|
163
143
|
// ---------------------------------------------------------------------------
|
|
164
144
|
/**
|
|
@@ -191,6 +171,23 @@ function isSymlinkedDestOptIn() {
|
|
|
191
171
|
const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
|
|
192
172
|
return v === '1' || v === 'true';
|
|
193
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* `lstatSync`, never following a symlink, returning `null` instead of
|
|
176
|
+
* throwing when `p` does not exist AT ALL (not even as a dangling symlink).
|
|
177
|
+
* Unlike `existsSync` (which follows symlinks and reports `false` for a
|
|
178
|
+
* dangling one), this correctly distinguishes "nothing here" from "a
|
|
179
|
+
* symlink is here, even if its target is missing" — see
|
|
180
|
+
* `hasExistingSymlinkBetween`'s own doc comment for why that distinction is
|
|
181
|
+
* security-load-bearing.
|
|
182
|
+
*/
|
|
183
|
+
function tryLstat(p) {
|
|
184
|
+
try {
|
|
185
|
+
return installFs().lstatSync(p);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
194
191
|
/**
|
|
195
192
|
* Returns true if any path component between `root` and `fullPath` is a
|
|
196
193
|
* symbolic link that would redirect writes outside the install root in a way
|
|
@@ -222,7 +219,7 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
222
219
|
// — threat (a) above still confines regardless.
|
|
223
220
|
let realRoot;
|
|
224
221
|
try {
|
|
225
|
-
realRoot =
|
|
222
|
+
realRoot = installFs().existsSync(resolvedRoot) ? installFs().realpathSync(resolvedRoot) : resolvedRoot;
|
|
226
223
|
}
|
|
227
224
|
catch {
|
|
228
225
|
realRoot = resolvedRoot;
|
|
@@ -236,12 +233,28 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
236
233
|
// circular back-reference to root from a path that descends from a resolved
|
|
237
234
|
// root. So under opt-in, just follow the root symlink and continue the walk.
|
|
238
235
|
// Default behavior (no opt-in) preserves the pre-#2393 refuse.
|
|
236
|
+
// #2875 defect fix: `existsSync` FOLLOWS symlinks and returns `false` for a
|
|
237
|
+
// DANGLING symlink (one whose target does not exist) — so the pre-fix
|
|
238
|
+
// `existsSync(cursor) && lstatSync(cursor).isSymbolicLink()` ordering used
|
|
239
|
+
// below (both here for `root` and in the per-segment loop) silently
|
|
240
|
+
// treated a dangling symlink as "nothing here", never even reaching the
|
|
241
|
+
// `lstatSync` symlink check. That let a dangling symlink planted AT a
|
|
242
|
+
// write destination — e.g. `<configDir>/USER-PROFILE.md ->
|
|
243
|
+
// <outside>/authorized_keys` — sail through this guard, after which the
|
|
244
|
+
// actual write (`copyFileSync` et al., which DOES follow symlinks) created
|
|
245
|
+
// attacker-controlled content outside the install root. `lstatSync` itself
|
|
246
|
+
// never follows a symlink and succeeds for a dangling one, so probing with
|
|
247
|
+
// it FIRST (falling back to "does not exist at all" only on ENOENT/similar)
|
|
248
|
+
// detects the dangling case correctly while preserving the exact same
|
|
249
|
+
// "cursor does not exist, stop walking" behavior for a path that truly has
|
|
250
|
+
// nothing there.
|
|
239
251
|
let cursor = resolvedRoot;
|
|
240
|
-
|
|
252
|
+
const cursorLstat = tryLstat(cursor);
|
|
253
|
+
if (cursorLstat && cursorLstat.isSymbolicLink()) {
|
|
241
254
|
if (!allowFollow)
|
|
242
255
|
return true;
|
|
243
256
|
try {
|
|
244
|
-
cursor =
|
|
257
|
+
cursor = installFs().realpathSync(cursor);
|
|
245
258
|
}
|
|
246
259
|
catch {
|
|
247
260
|
// realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
|
|
@@ -254,9 +267,10 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
254
267
|
if (!segment)
|
|
255
268
|
continue;
|
|
256
269
|
cursor = node_path_1.default.join(cursor, segment);
|
|
257
|
-
|
|
270
|
+
const segmentLstat = tryLstat(cursor);
|
|
271
|
+
if (!segmentLstat)
|
|
258
272
|
return false;
|
|
259
|
-
if (
|
|
273
|
+
if (segmentLstat.isSymbolicLink()) {
|
|
260
274
|
if (!allowFollow)
|
|
261
275
|
return true;
|
|
262
276
|
// Opt-in active: follow the symlink. Refuse if the resolved target is the
|
|
@@ -278,7 +292,7 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
278
292
|
// documented opt-in semantics; do not add a "follow one symlink only"
|
|
279
293
|
// expectation here without revisiting the threat model.
|
|
280
294
|
try {
|
|
281
|
-
const realTarget =
|
|
295
|
+
const realTarget = installFs().realpathSync(cursor);
|
|
282
296
|
if (realTarget === realRoot || realTarget === resolvedRoot)
|
|
283
297
|
return true; // (b)
|
|
284
298
|
cursor = realTarget;
|
|
@@ -291,6 +305,56 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
291
305
|
return false;
|
|
292
306
|
}
|
|
293
307
|
// ---------------------------------------------------------------------------
|
|
308
|
+
// User-artifact staging root
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/**
|
|
311
|
+
* Resolve the durable staging root for `configDir` (#2875 / user-artifact-
|
|
312
|
+
* staging.cts), confined via the SAME `assertDestWithinConfigHome` gate every
|
|
313
|
+
* other write on this call tree uses, and refused via the SAME
|
|
314
|
+
* `hasExistingSymlinkBetween` guard `_copyStaged`/
|
|
315
|
+
* `migrateLegacyDevPreferencesToSkill` already apply to their own writes
|
|
316
|
+
* (test-matrix E1/E4) — this module never reimplements either decision, only
|
|
317
|
+
* reuses them (user-artifact-staging.cts's own module doc, "Confinement").
|
|
318
|
+
*
|
|
319
|
+
* Fixed location: `<configDir>/.gsd-staging/user-artifacts/` — a sibling of
|
|
320
|
+
* every directory this phase's four call sites wipe, so staging survives all
|
|
321
|
+
* of them while staying inside configDir (40-design.md "Staging location").
|
|
322
|
+
*/
|
|
323
|
+
function _resolveUserArtifactStagingRoot(configDir) {
|
|
324
|
+
const stagingRoot = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.posix.join('.gsd-staging', 'user-artifacts'));
|
|
325
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), stagingRoot, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
326
|
+
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.`);
|
|
327
|
+
}
|
|
328
|
+
return stagingRoot;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Degrade-not-abort wrapper over `_resolveUserArtifactStagingRoot` (defect
|
|
332
|
+
* fix — a hostile/broken `.gsd-staging` path, or a symlinked configDir
|
|
333
|
+
* itself, e.g. nix-darwin/dotfiles-managed `~/.claude`, GSD_ALLOW_SYMLINKED_DEST's
|
|
334
|
+
* own population) must never brick the command it is called from. Before
|
|
335
|
+
* this fix `_resolveUserArtifactStagingRoot` was called UNGUARDED as the
|
|
336
|
+
* first statement of both `install()` and `uninstall()` (bin/install.js) —
|
|
337
|
+
* `ln -s /nonexistent ~/.claude/.gsd-staging` killed both commands,
|
|
338
|
+
* including uninstall, the remedy for the first problem.
|
|
339
|
+
*
|
|
340
|
+
* Returns `null` (never throws) when staging is unavailable, logging ONE
|
|
341
|
+
* warning naming the underlying cause. Every call site MUST treat `null` as
|
|
342
|
+
* "skip the staging-dependent step for this run" — the same "degrade,
|
|
343
|
+
* never throw" posture user-artifact-staging.cts's own recovery/restore
|
|
344
|
+
* functions already document (module doc "Failure posture"), extended to
|
|
345
|
+
* cover staging-ROOT resolution itself, not just the copy/restore that
|
|
346
|
+
* follows it.
|
|
347
|
+
*/
|
|
348
|
+
function _tryResolveUserArtifactStagingRoot(configDir) {
|
|
349
|
+
try {
|
|
350
|
+
return _resolveUserArtifactStagingRoot(configDir);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.warn(` [gsd] user-artifact staging unavailable for "${configDir}" (${err.message}) — proceeding without durable staging for this step.`);
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
294
358
|
// migrateLegacyDevPreferencesToSkill
|
|
295
359
|
// ---------------------------------------------------------------------------
|
|
296
360
|
/**
|
|
@@ -308,14 +372,36 @@ function hasExistingSymlinkBetween(root, fullPath, options = {}) {
|
|
|
308
372
|
* migration so callers can log a one-line confirmation.
|
|
309
373
|
*
|
|
310
374
|
* @param targetDir - Resolved runtime config directory (e.g. ~/.claude)
|
|
311
|
-
* @param saved - Map
|
|
375
|
+
* @param saved - Map of fileName -> content, built by the caller from a
|
|
376
|
+
* user-artifact-staging.cts staged batch's disk contents (#2875) — every
|
|
377
|
+
* call site reads this back AFTER its own wipe, never held in memory
|
|
378
|
+
* across it.
|
|
312
379
|
* @param runtime - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
|
|
313
380
|
* @param scope - install scope
|
|
314
381
|
* @returns true if a file was migrated, false otherwise
|
|
315
382
|
*/
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Resolve the `{ skillFile, installRoot }` `migrateLegacyDevPreferencesToSkill`
|
|
385
|
+
* would target for `(targetDir, runtime, scope)`, WITHOUT performing any
|
|
386
|
+
* write. Extracted (#2875 defect fix) purely as a resolution helper so a
|
|
387
|
+
* caller can determine whether migration is even POSSIBLE for this
|
|
388
|
+
* runtime/scope, and whether it is already SATISFIED (a skill file already
|
|
389
|
+
* present), BEFORE deciding whether discarding a staged legacy copy would
|
|
390
|
+
* lose the user's file — `migrateLegacyDevPreferencesToSkill`'s own boolean
|
|
391
|
+
* return conflates "no skills layout for this runtime" with "the write
|
|
392
|
+
* failed" with "already migrated": all three return `false` today, and
|
|
393
|
+
* changing that return SHAPE would also change bin/install.js's own
|
|
394
|
+
* `if (migrateLegacyDevPreferencesToSkill(...))` call site, which this
|
|
395
|
+
* module does not own. This helper changes nothing about
|
|
396
|
+
* `migrateLegacyDevPreferencesToSkill`'s own signature or behavior — it is
|
|
397
|
+
* now IMPLEMENTED in terms of this helper, so there is exactly one copy of
|
|
398
|
+
* the resolution logic, never two that could drift.
|
|
399
|
+
*
|
|
400
|
+
* @returns `{ skillFile, installRoot }`, or `null` if this runtime/scope has
|
|
401
|
+
* no skills layout to migrate into (mirrors `migrateLegacyDevPreferencesToSkill`'s
|
|
402
|
+
* own early return for that case).
|
|
403
|
+
*/
|
|
404
|
+
function _resolveDevPreferencesSkillTarget(targetDir, runtime, scope = 'global') {
|
|
319
405
|
let skillDir;
|
|
320
406
|
// #2911: the actual install root the skill dir resolves under — defaults to
|
|
321
407
|
// targetDir, but a skills-kind `home` override (e.g. Codex -> $HOME/.agents)
|
|
@@ -327,7 +413,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
327
413
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope);
|
|
328
414
|
const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
|
|
329
415
|
if (!skillsKindEntry)
|
|
330
|
-
return
|
|
416
|
+
return null; // runtime has no skills layout at this scope (e.g. cline local)
|
|
331
417
|
const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
|
|
332
418
|
// #2911: same destination-root defect as _copyStaged/applySurface — honor
|
|
333
419
|
// skillsKindEntry.home as a FALLBACK-preferred override (e.g. Codex skills
|
|
@@ -341,9 +427,35 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
341
427
|
// Legacy fallback for callers that have not yet been updated to pass runtime
|
|
342
428
|
skillDir = node_path_1.default.join(runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, 'skills'), 'gsd-dev-preferences');
|
|
343
429
|
}
|
|
344
|
-
|
|
345
|
-
|
|
430
|
+
return { skillFile: node_path_1.default.join(skillDir, 'SKILL.md'), installRoot };
|
|
431
|
+
}
|
|
432
|
+
function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
|
|
433
|
+
if (!saved || !saved.has('dev-preferences.md'))
|
|
346
434
|
return false;
|
|
435
|
+
const target = _resolveDevPreferencesSkillTarget(targetDir, runtime, scope);
|
|
436
|
+
if (!target)
|
|
437
|
+
return false; // runtime has no skills layout at this scope (e.g. cline local)
|
|
438
|
+
const { skillFile, installRoot } = target;
|
|
439
|
+
const skillDir = node_path_1.default.dirname(skillFile);
|
|
440
|
+
// Security fix: `existsSync` FOLLOWS symlinks and reports `false` for a
|
|
441
|
+
// DANGLING one, so the prior `existsSync(skillFile)` check never even saw a
|
|
442
|
+
// dangling symlink planted AT the leaf (e.g.
|
|
443
|
+
// `<installRoot>/skills/gsd-dev-preferences/SKILL.md ->
|
|
444
|
+
// ~/.ssh/authorized_keys`) — it fell through past this "already migrated"
|
|
445
|
+
// bail, past the symlink-escape guard below (which only walks to `skillDir`,
|
|
446
|
+
// the parent DIRECTORY, and never lstats the leaf FILE itself), and into
|
|
447
|
+
// `writeFileSync`, which DOES follow symlinks and would have written
|
|
448
|
+
// attacker-chosen `saved` content to the symlink's target. `tryLstat` never
|
|
449
|
+
// follows a symlink and distinguishes "a real file is already here" (skip,
|
|
450
|
+
// same as before) from "a symlink (dangling or not) is planted here"
|
|
451
|
+
// (refuse — this is never a legitimate prior-migration state).
|
|
452
|
+
const skillFileLstat = tryLstat(skillFile);
|
|
453
|
+
if (skillFileLstat) {
|
|
454
|
+
if (skillFileLstat.isSymbolicLink()) {
|
|
455
|
+
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).`);
|
|
456
|
+
}
|
|
457
|
+
return false; // a real file is already there — already migrated, skip
|
|
458
|
+
}
|
|
347
459
|
// Symlink-escape guard: reject if any path component between installRoot and
|
|
348
460
|
// skillDir is a symlink that would redirect writes outside the install root.
|
|
349
461
|
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
@@ -351,8 +463,8 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
351
463
|
throw new Error(`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.`);
|
|
352
464
|
}
|
|
353
465
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
466
|
+
installFs().mkdirSync(skillDir, { recursive: true });
|
|
467
|
+
installFs().writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
|
|
356
468
|
return true;
|
|
357
469
|
}
|
|
358
470
|
catch {
|
|
@@ -402,31 +514,32 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
|
|
|
402
514
|
}
|
|
403
515
|
// Use the validated absolute path for the actual writes below.
|
|
404
516
|
destDir = resolvedDest;
|
|
405
|
-
if (!
|
|
517
|
+
if (!installFs().existsSync(stagedDir))
|
|
406
518
|
return;
|
|
407
|
-
|
|
519
|
+
installFs().mkdirSync(destDir, { recursive: true });
|
|
408
520
|
if (kind.kind === 'skills') {
|
|
409
521
|
// Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
|
|
410
|
-
for (const entry of
|
|
522
|
+
for (const entry of installFs().readdirSync(stagedDir, { withFileTypes: true })) {
|
|
411
523
|
if (!entry.isDirectory())
|
|
412
524
|
continue;
|
|
413
525
|
const src = node_path_1.default.join(stagedDir, entry.name);
|
|
414
526
|
const dest = node_path_1.default.join(destDir, entry.name);
|
|
415
|
-
|
|
527
|
+
installFs().cpSync(src, dest, { recursive: true });
|
|
416
528
|
}
|
|
417
529
|
return;
|
|
418
530
|
}
|
|
419
531
|
if (kind.kind === 'kimi-agents') {
|
|
420
|
-
|
|
532
|
+
installFs().cpSync(stagedDir, destDir, { recursive: true });
|
|
421
533
|
return;
|
|
422
534
|
}
|
|
423
535
|
// commands or agents
|
|
424
|
-
const entries =
|
|
536
|
+
const entries = installFs().readdirSync(stagedDir, { withFileTypes: true });
|
|
425
537
|
// For commands: apply prefix unless the destSubpath's last segment already
|
|
426
538
|
// represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
539
|
+
// Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
|
|
540
|
+
// Phase 2 review finding — this rule previously drifted independently
|
|
541
|
+
// across install-engine.cts / surface.cts / runtime-artifact-layout.cts).
|
|
542
|
+
const namespacedByDir = runtimeArtifactLayout.isNamespacedByDir(kind.kind, kind.destSubpath, kind.prefix);
|
|
430
543
|
for (const entry of entries) {
|
|
431
544
|
if (!entry.isFile())
|
|
432
545
|
continue;
|
|
@@ -445,15 +558,16 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
|
|
|
445
558
|
? entry.name.replace(/\.md$/, _agentExt)
|
|
446
559
|
: entry.name;
|
|
447
560
|
}
|
|
448
|
-
else if (namespacedByDir) {
|
|
449
|
-
// Directory is the namespace; don't double-prefix the filename
|
|
450
|
-
destName = entry.name;
|
|
451
|
-
}
|
|
452
561
|
else {
|
|
453
|
-
//
|
|
454
|
-
|
|
562
|
+
// Commands: filename composition (namespacedByDir ? `${stem}.md` :
|
|
563
|
+
// `${prefix}${stem}.md`) is single-sourced with resolveTriggerSurface's
|
|
564
|
+
// destPath prediction via composeCommandFilename (#2871 Phase 2 review
|
|
565
|
+
// finding). Byte-identical to the prior separate namespacedByDir/flat
|
|
566
|
+
// branches — see that helper's doc comment for why the namespacedByDir
|
|
567
|
+
// case reconstructing `${stem}.md` is always exactly `entry.name`.
|
|
568
|
+
destName = runtimeArtifactLayout.composeCommandFilename(namespacedByDir, kind.prefix, stem);
|
|
455
569
|
}
|
|
456
|
-
|
|
570
|
+
installFs().copyFileSync(node_path_1.default.join(stagedDir, entry.name), node_path_1.default.join(destDir, destName));
|
|
457
571
|
}
|
|
458
572
|
}
|
|
459
573
|
// ---------------------------------------------------------------------------
|
|
@@ -466,22 +580,22 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
|
|
|
466
580
|
* as a defensive guard for future runtimes.)
|
|
467
581
|
*/
|
|
468
582
|
function _removeGsdEntries(destDir, kind) {
|
|
469
|
-
if (!
|
|
583
|
+
if (!installFs().existsSync(destDir))
|
|
470
584
|
return;
|
|
471
585
|
if (kind.kind === 'kimi-agents') {
|
|
472
586
|
for (const fileName of ['gsd.yaml', 'gsd.md']) {
|
|
473
|
-
|
|
587
|
+
installFs().rmSync(node_path_1.default.join(destDir, fileName), { force: true });
|
|
474
588
|
}
|
|
475
589
|
const subagentsDir = node_path_1.default.join(destDir, 'subagents');
|
|
476
|
-
if (
|
|
477
|
-
for (const entry of
|
|
590
|
+
if (installFs().existsSync(subagentsDir)) {
|
|
591
|
+
for (const entry of installFs().readdirSync(subagentsDir, { withFileTypes: true })) {
|
|
478
592
|
if (!entry.isFile())
|
|
479
593
|
continue;
|
|
480
594
|
if (!entry.name.startsWith('gsd-'))
|
|
481
595
|
continue;
|
|
482
596
|
if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md'))
|
|
483
597
|
continue;
|
|
484
|
-
|
|
598
|
+
installFs().rmSync(node_path_1.default.join(subagentsDir, entry.name), { force: true });
|
|
485
599
|
}
|
|
486
600
|
}
|
|
487
601
|
return;
|
|
@@ -489,13 +603,13 @@ function _removeGsdEntries(destDir, kind) {
|
|
|
489
603
|
if (kind.prefix === '') {
|
|
490
604
|
// Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
|
|
491
605
|
// The directory itself is the GSD namespace, so remove it entirely.
|
|
492
|
-
|
|
606
|
+
installFs().rmSync(destDir, { recursive: true, force: true });
|
|
493
607
|
return;
|
|
494
608
|
}
|
|
495
|
-
for (const entry of
|
|
609
|
+
for (const entry of installFs().readdirSync(destDir, { withFileTypes: true })) {
|
|
496
610
|
if (!entry.name.startsWith(kind.prefix))
|
|
497
611
|
continue;
|
|
498
|
-
|
|
612
|
+
installFs().rmSync(node_path_1.default.join(destDir, entry.name), { recursive: true, force: true });
|
|
499
613
|
}
|
|
500
614
|
}
|
|
501
615
|
// ---------------------------------------------------------------------------
|
|
@@ -507,16 +621,16 @@ function _removeGsdEntries(destDir, kind) {
|
|
|
507
621
|
*/
|
|
508
622
|
function _snapshotDir(dir) {
|
|
509
623
|
const files = new Map();
|
|
510
|
-
if (!
|
|
624
|
+
if (!installFs().existsSync(dir))
|
|
511
625
|
return files;
|
|
512
626
|
const walk = (relPath, absPath) => {
|
|
513
|
-
for (const e of
|
|
627
|
+
for (const e of installFs().readdirSync(absPath, { withFileTypes: true })) {
|
|
514
628
|
const childRel = relPath ? node_path_1.default.join(relPath, e.name) : e.name;
|
|
515
629
|
const childAbs = node_path_1.default.join(absPath, e.name);
|
|
516
630
|
if (e.isDirectory())
|
|
517
631
|
walk(childRel, childAbs);
|
|
518
632
|
else if (e.isFile())
|
|
519
|
-
files.set(childRel,
|
|
633
|
+
files.set(childRel, installFs().readFileSync(childAbs));
|
|
520
634
|
}
|
|
521
635
|
};
|
|
522
636
|
walk('', dir);
|
|
@@ -528,8 +642,8 @@ function _snapshotDir(dir) {
|
|
|
528
642
|
function _restoreDir(dir, snapshot) {
|
|
529
643
|
for (const [relPath, buf] of snapshot) {
|
|
530
644
|
const absPath = node_path_1.default.join(dir, relPath);
|
|
531
|
-
|
|
532
|
-
|
|
645
|
+
installFs().mkdirSync(node_path_1.default.dirname(absPath), { recursive: true });
|
|
646
|
+
installFs().writeFileSync(absPath, buf);
|
|
533
647
|
}
|
|
534
648
|
}
|
|
535
649
|
// ---------------------------------------------------------------------------
|
|
@@ -543,9 +657,9 @@ function _restoreDir(dir, snapshot) {
|
|
|
543
657
|
* @param nestedGsdDir absolute path to skills/gsd/ category dir
|
|
544
658
|
*/
|
|
545
659
|
function _removeHermesBareStemDirs(nestedGsdDir) {
|
|
546
|
-
if (!
|
|
660
|
+
if (!installFs().existsSync(nestedGsdDir))
|
|
547
661
|
return;
|
|
548
|
-
const entries =
|
|
662
|
+
const entries = installFs().readdirSync(nestedGsdDir, { withFileTypes: true });
|
|
549
663
|
// Collect the set of stems that were installed as gsd-<stem>/ this run.
|
|
550
664
|
const installedStems = new Set();
|
|
551
665
|
for (const entry of entries) {
|
|
@@ -556,7 +670,7 @@ function _removeHermesBareStemDirs(nestedGsdDir) {
|
|
|
556
670
|
// Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
|
|
557
671
|
for (const entry of entries) {
|
|
558
672
|
if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
|
|
559
|
-
|
|
673
|
+
installFs().rmSync(node_path_1.default.join(nestedGsdDir, entry.name), { recursive: true });
|
|
560
674
|
}
|
|
561
675
|
}
|
|
562
676
|
}
|
|
@@ -577,21 +691,38 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
|
|
577
691
|
// for migration. The actual migration call is deferred to after all layout cleanup so
|
|
578
692
|
// that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
|
|
579
693
|
// created skills/gsd-dev-preferences/ skill dir.
|
|
580
|
-
let
|
|
694
|
+
let stagedLegacyArtifacts = null;
|
|
581
695
|
if (_hostBehaviors(runtime).legacyCommandsGsdInstallMigration) {
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
696
|
+
if (installFs().existsSync(legacyCommandsGsd)) {
|
|
697
|
+
// #2875: staging root resolved lazily, only when there is actually
|
|
698
|
+
// something to stage — reused below by every other call site sharing
|
|
699
|
+
// this configDir.
|
|
700
|
+
// #2875 defect fix: DEGRADE, never abort the whole install, when the
|
|
701
|
+
// staging root itself cannot be resolved (e.g. a hostile/broken
|
|
702
|
+
// `.gsd-staging` symlink) — skip this legacy-migration block entirely
|
|
703
|
+
// rather than wipe legacyCommandsGsd without a durable backup (module
|
|
704
|
+
// doc "Failure posture": a wipe having staged nothing is worse than no
|
|
705
|
+
// staging at all). The stale legacy dir is simply left in place for a
|
|
706
|
+
// future successful run.
|
|
707
|
+
const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir);
|
|
708
|
+
if (stagingRoot !== null) {
|
|
709
|
+
// #2875 (#1874-F19): staged DURABLY to disk before the wipe below, so a
|
|
710
|
+
// crash anywhere in this function — including the Hermes flat-skills
|
|
711
|
+
// wipe further down, previously inside the same in-memory-only window
|
|
712
|
+
// — survives via recoverOrphanedUserArtifacts on the next run.
|
|
713
|
+
stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot);
|
|
714
|
+
installFs().rmSync(legacyCommandsGsd, { recursive: true });
|
|
715
|
+
}
|
|
585
716
|
}
|
|
586
717
|
}
|
|
587
718
|
// Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
|
|
588
719
|
// the new skills/gsd/ nested layout.
|
|
589
720
|
if (runtime === 'hermes') {
|
|
590
721
|
const flatSkillsDir = node_path_1.default.join(configDir, 'skills');
|
|
591
|
-
if (
|
|
592
|
-
for (const entry of
|
|
722
|
+
if (installFs().existsSync(flatSkillsDir)) {
|
|
723
|
+
for (const entry of installFs().readdirSync(flatSkillsDir, { withFileTypes: true })) {
|
|
593
724
|
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
|
|
594
|
-
|
|
725
|
+
installFs().rmSync(node_path_1.default.join(flatSkillsDir, entry.name), { recursive: true });
|
|
595
726
|
}
|
|
596
727
|
}
|
|
597
728
|
}
|
|
@@ -604,8 +735,90 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
|
|
604
735
|
// Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
|
|
605
736
|
// Done after all layout cleanup so Hermes flat-dir removal does not delete the
|
|
606
737
|
// newly created skill dir. No-op if skill file already exists.
|
|
607
|
-
if (
|
|
608
|
-
|
|
738
|
+
if (stagedLegacyArtifacts) {
|
|
739
|
+
// #2875: read the content back from the DISK-staged copy (fresh, after
|
|
740
|
+
// every wipe above has already run) rather than an in-memory value held
|
|
741
|
+
// across them.
|
|
742
|
+
//
|
|
743
|
+
// #2875 defect fix (readFileSync following a staged symlink):
|
|
744
|
+
// readFileSync ALWAYS follows a symlink — a staged artifact that is
|
|
745
|
+
// itself a symlink (module doc "Symlink safety", A4: staging never
|
|
746
|
+
// dereferences a symlink; a symlinked USER-artifact is recreated AS a
|
|
747
|
+
// symlink in the staging tree, not copied by content) would have its
|
|
748
|
+
// REFERENT's bytes read here and land in SKILL.md, violating this
|
|
749
|
+
// module's own "referent bytes never read" contract. A symlinked staged
|
|
750
|
+
// name is excluded from migration below and restored to its original
|
|
751
|
+
// location instead — migrating a symlink AS skill-file text content is
|
|
752
|
+
// not a coherent operation to begin with.
|
|
753
|
+
const savedLegacyArtifacts = new Map();
|
|
754
|
+
const migratableNames = [];
|
|
755
|
+
for (const name of stagedLegacyArtifacts.names) {
|
|
756
|
+
const stagedPath = node_path_1.default.join(stagedLegacyArtifacts.filesDir, name);
|
|
757
|
+
// #2875 defect fix (crash resilience — TOCTOU): a raw `lstatSync` throws
|
|
758
|
+
// if `stagedPath` has vanished between staging (above) and this read —
|
|
759
|
+
// e.g. a co-resident attacker on a shared machine racing the staging
|
|
760
|
+
// dir, the exact threat class this module's own "Confinement" doc
|
|
761
|
+
// already treats as live. Every sibling probe in this file (`tryLstat`
|
|
762
|
+
// itself, and its use at `skillFileLstat` above) already degrades
|
|
763
|
+
// rather than throws; do the same here — a vanished staged file is
|
|
764
|
+
// simply not migratable, matching A2's "absent, not staged, no throw"
|
|
765
|
+
// precedent in user-artifact-staging.cts.
|
|
766
|
+
const stagedLstat = tryLstat(stagedPath);
|
|
767
|
+
if (!stagedLstat || stagedLstat.isSymbolicLink())
|
|
768
|
+
continue;
|
|
769
|
+
savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8'));
|
|
770
|
+
migratableNames.push(name);
|
|
771
|
+
}
|
|
772
|
+
// #2875 defect fix (regression closed — was previously unguarded and
|
|
773
|
+
// BRICKED the command): migrateLegacyDevPreferencesToSkill correctly
|
|
774
|
+
// THROWS when it finds a planted/dangling symlink at the skill-file leaf
|
|
775
|
+
// (security fix — refusing to write through it is correct) but by this
|
|
776
|
+
// point legacyCommandsGsd has ALREADY been wiped (rmSync above) and
|
|
777
|
+
// stagedLegacyArtifacts is the only surviving copy. An unguarded throw
|
|
778
|
+
// here propagated straight out of installRuntimeArtifacts, aborting the
|
|
779
|
+
// whole install/uninstall WITHOUT ever reaching the restore-or-discard
|
|
780
|
+
// logic below — the staged batch was orphaned on disk and every retry
|
|
781
|
+
// hit the same throw again (same brick-the-command failure mode this
|
|
782
|
+
// module's "DEGRADE, never abort" posture, see
|
|
783
|
+
// _tryResolveUserArtifactStagingRoot above, already closed for a broken
|
|
784
|
+
// `.gsd-staging` path). Degrade identically: catch, warn once, and treat
|
|
785
|
+
// the batch as unmigrated so the restore branch below fires.
|
|
786
|
+
let migrated = false;
|
|
787
|
+
let migrationRefused = false;
|
|
788
|
+
try {
|
|
789
|
+
migrated = migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
console.warn(` [gsd] dev-preferences.md migration skipped for "${configDir}" (${err.message}) — restoring the legacy copy instead.`);
|
|
793
|
+
migrationRefused = true;
|
|
794
|
+
}
|
|
795
|
+
// #2875 defect fix (call site 1 was a loss site): migrateLegacyDevPreferencesToSkill's
|
|
796
|
+
// boolean return conflates "migrated", "already satisfied" (skill file
|
|
797
|
+
// already present — safe to discard either way), and "cannot migrate"
|
|
798
|
+
// (no skills layout for this runtime, or the write itself failed —
|
|
799
|
+
// discarding here would silently lose the user's file, the exact loss
|
|
800
|
+
// this whole module exists to prevent). Distinguish via the resolved
|
|
801
|
+
// target's actual presence rather than trusting the boolean alone; a
|
|
802
|
+
// symlinked staged name (excluded from migration above) is treated the
|
|
803
|
+
// same way — never migrated, so it must not be silently discarded.
|
|
804
|
+
//
|
|
805
|
+
// #2875 defect fix (migrationRefused must short-circuit this to `false`,
|
|
806
|
+
// never fall through to the existsSync probe below): when
|
|
807
|
+
// migrateLegacyDevPreferencesToSkill refused because skillTarget.skillFile
|
|
808
|
+
// is a symlink, `existsSync` FOLLOWS it — a symlink pointing at some
|
|
809
|
+
// OTHER real file (not dangling) would read back `true` here and mark
|
|
810
|
+
// the batch "satisfied", discarding it without ever restoring it. Refusal
|
|
811
|
+
// is never satisfaction.
|
|
812
|
+
const skillTarget = migrationRefused ? null : _resolveDevPreferencesSkillTarget(configDir, runtime, scope);
|
|
813
|
+
const migrationSatisfied = !migrationRefused && (migrated || (skillTarget !== null && installFs().existsSync(skillTarget.skillFile)));
|
|
814
|
+
const nothingLeftUnmigrated = migrationSatisfied && migratableNames.length === stagedLegacyArtifacts.names.length;
|
|
815
|
+
if (!nothingLeftUnmigrated && stagedLegacyArtifacts.names.length > 0) {
|
|
816
|
+
// Put the whole batch back where it came from rather than losing
|
|
817
|
+
// whatever migration did not (or could not) account for.
|
|
818
|
+
installFs().mkdirSync(legacyCommandsGsd, { recursive: true });
|
|
819
|
+
userArtifactStaging.restoreStagedUserArtifacts(legacyCommandsGsd, stagedLegacyArtifacts);
|
|
820
|
+
}
|
|
821
|
+
userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts);
|
|
609
822
|
}
|
|
610
823
|
}
|
|
611
824
|
/**
|
|
@@ -615,7 +828,7 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
|
|
615
828
|
* @param runtime
|
|
616
829
|
* @param configDir resolved runtime config directory
|
|
617
830
|
* @param scope
|
|
618
|
-
* @returns
|
|
831
|
+
* @returns staged legacy artifacts for post-removal migration, or null
|
|
619
832
|
*/
|
|
620
833
|
function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
621
834
|
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
|
|
@@ -629,19 +842,42 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
629
842
|
// is deferred and returned so the caller can apply it AFTER layout-driven
|
|
630
843
|
// removal — this prevents the layout's gsd-* prefix removal from wiping the
|
|
631
844
|
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
|
|
632
|
-
|
|
845
|
+
// #2875 (#1874-F19): staged DURABLY to disk (userArtifactStaging), not just
|
|
846
|
+
// an in-memory Map — this function's own wipe below is raw `fs`, left
|
|
847
|
+
// unrouted by design (Phase 5 deliberately left the uninstall tree off the
|
|
848
|
+
// installFs() seam; 40-design.md "Explicitly out of scope"), but the
|
|
849
|
+
// staging call itself still routes through installFs() because the shared
|
|
850
|
+
// module does (ambient default: real fs here, since this call is never
|
|
851
|
+
// wrapped in withInstallFs).
|
|
852
|
+
let stagedLegacyArtifacts = null;
|
|
633
853
|
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
|
|
634
854
|
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
|
|
635
855
|
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
|
|
636
856
|
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
|
|
637
857
|
// (which would redirect to skills/) conflicts with the test contract for local installs.
|
|
638
858
|
const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
|
|
639
|
-
|
|
859
|
+
// #2870: `scope` keeps its exported `string = 'global'` signature (no
|
|
860
|
+
// signature change), but every real caller — `uninstallRuntimeArtifacts`'s
|
|
861
|
+
// own required `scope` param, always fed a validated 'global' | 'local'
|
|
862
|
+
// literal by bin/install.js's scope-resolution ternary, plus every direct
|
|
863
|
+
// test call site — only ever supplies 'global' or 'local'. The existing
|
|
864
|
+
// `= 'global'` default already reproduces today's behavior for an omitted
|
|
865
|
+
// scope, so the cast below is safe: `isGlobalScope` never sees a value
|
|
866
|
+
// outside its union here.
|
|
867
|
+
const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && (0, install_scope_cjs_1.isGlobalScope)(scope));
|
|
640
868
|
if (isLegacyCommandsGsd) {
|
|
641
869
|
const legacyCommandsGsd = node_path_1.default.join(configDir, 'commands', 'gsd');
|
|
642
870
|
if (node_fs_1.default.existsSync(legacyCommandsGsd)) {
|
|
643
|
-
|
|
644
|
-
|
|
871
|
+
// #2875 defect fix: DEGRADE, never abort uninstall, when the staging
|
|
872
|
+
// root cannot be resolved — skip this legacy-cleanup block (leave the
|
|
873
|
+
// stale dir in place) rather than wipe without a durable backup.
|
|
874
|
+
// Uninstall in particular must always be able to proceed past this
|
|
875
|
+
// point regardless of a hostile/broken `.gsd-staging` path.
|
|
876
|
+
const stagingRoot = _tryResolveUserArtifactStagingRoot(configDir);
|
|
877
|
+
if (stagingRoot !== null) {
|
|
878
|
+
stagedLegacyArtifacts = userArtifactStaging.stageUserArtifacts(legacyCommandsGsd, ['dev-preferences.md'], stagingRoot);
|
|
879
|
+
node_fs_1.default.rmSync(legacyCommandsGsd, { recursive: true });
|
|
880
|
+
}
|
|
645
881
|
}
|
|
646
882
|
}
|
|
647
883
|
// Hermes: pre-#2841 flat skills/gsd-*/ entries
|
|
@@ -666,8 +902,8 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
666
902
|
}
|
|
667
903
|
}
|
|
668
904
|
}
|
|
669
|
-
// Return
|
|
670
|
-
return
|
|
905
|
+
// Return staged artifacts so the caller can migrate after layout-driven removal.
|
|
906
|
+
return stagedLegacyArtifacts;
|
|
671
907
|
}
|
|
672
908
|
// ---------------------------------------------------------------------------
|
|
673
909
|
// installRuntimeArtifacts
|
|
@@ -687,137 +923,230 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
687
923
|
* the skills kind can materialize installed third-party capability skills
|
|
688
924
|
* bound to their declaring capId. Absent -> no third-party skills staged
|
|
689
925
|
* (fail closed), matching the layout resolver's own optional-registry contract.
|
|
926
|
+
* @param deps #2874 (ADR-58 cleanup phase): optional injection bag, additive
|
|
927
|
+
* over the 6-positional-arg call shape every existing caller (bin/install.js,
|
|
928
|
+
* G1/G3 test doubles) already uses — an omitted/`{}` `deps` is byte-identical
|
|
929
|
+
* to before (AC4). `deps.fs` — a PARTIAL InstallFsAdapter
|
|
930
|
+
* (install-fs-adapter.cts) — is merged over the real fs adapter for the
|
|
931
|
+
* duration of this call (and everything it calls: layout source-root
|
|
932
|
+
* resolution, profile staging, content-rewrite passes) via `withInstallFs`.
|
|
933
|
+
* @returns an executed-plan value describing what this call wrote, never
|
|
934
|
+
* `undefined` (40-design.md: "Legitimate undefined returns: none after this
|
|
935
|
+
* phase"). Throws, rather than returning an `ok:false` shape, on stage/
|
|
936
|
+
* rewrite failure — the return type describes what executed; failure stays
|
|
937
|
+
* an exception (design doc "Rejected" #3 / AC4).
|
|
690
938
|
*/
|
|
691
|
-
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
|
|
712
|
-
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
|
713
|
-
// `Layout` is structurally identical across the layout/install-plan .cjs
|
|
714
|
-
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
|
715
|
-
layout: layout,
|
|
716
|
-
resolvedProfile,
|
|
717
|
-
homedir: () => node_os_1.default.homedir(),
|
|
718
|
-
platform: process.platform,
|
|
719
|
-
resolveAttribution,
|
|
720
|
-
});
|
|
721
|
-
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
|
722
|
-
try {
|
|
723
|
-
if (!planResult.ok) {
|
|
724
|
-
throw new Error(planResult.message);
|
|
939
|
+
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, capabilityRegistry, deps = {}) {
|
|
940
|
+
return withInstallFs(deps.fs, () => {
|
|
941
|
+
// A removed descriptor kind is no longer visited by the layout loop, so it
|
|
942
|
+
// cannot prune its own previous output. Clean manifest-proven retired files
|
|
943
|
+
// before materializing the current layout (#2644).
|
|
944
|
+
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
|
945
|
+
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
|
946
|
+
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
|
947
|
+
// generic layout-driven loop below, mirroring the bespoke install path that
|
|
948
|
+
// previously lived inline in bin/install.js.
|
|
949
|
+
const behaviors = _hostBehaviors(runtime);
|
|
950
|
+
if (behaviors.combinedFamilyInstall) {
|
|
951
|
+
// #2329: combined-family runtimes (OpenCode/Kilo) bypass
|
|
952
|
+
// _runLegacyInstallMigrations below entirely (early return), so their
|
|
953
|
+
// legacy-directory cleanup needs its own pre-materialization hook here.
|
|
954
|
+
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
|
955
|
+
// #2874 design row 2: this early return must ALSO return an executed
|
|
956
|
+
// plan — installOpencodeFamilyArtifacts reports what it wrote, so a
|
|
957
|
+
// whole runtime family returning undefined is no longer a hole.
|
|
958
|
+
return installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
|
725
959
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
960
|
+
// Legacy cleanup before layout-driven writes
|
|
961
|
+
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
962
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope, capabilityRegistry);
|
|
963
|
+
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
|
|
964
|
+
// `Layout` is structurally identical across the layout/install-plan .cjs
|
|
965
|
+
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.
|
|
966
|
+
layout: layout,
|
|
967
|
+
resolvedProfile,
|
|
968
|
+
homedir: () => node_os_1.default.homedir(),
|
|
969
|
+
platform: process.platform,
|
|
970
|
+
resolveAttribution,
|
|
971
|
+
});
|
|
972
|
+
const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
|
|
973
|
+
// #2874 row 1/4/5: per-kind executed-plan entries, appended only as the
|
|
974
|
+
// loop below actually finishes writing each kind — a kind that throws
|
|
975
|
+
// mid-copy is never reported as executed.
|
|
976
|
+
const executedKinds = [];
|
|
977
|
+
// #2874 rows 10/11: { dir, ok } per cleanupDirs entry — built in the
|
|
978
|
+
// `finally` below regardless of whether the try block throws, so a
|
|
979
|
+
// caught failure that still throws (row 3) leaves this populated even
|
|
980
|
+
// though it is never returned on that path.
|
|
981
|
+
const cleanupResults = [];
|
|
982
|
+
try {
|
|
983
|
+
if (!planResult.ok) {
|
|
984
|
+
throw new Error(planResult.message);
|
|
746
985
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
986
|
+
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
|
|
987
|
+
for (const item of planResult.plan.items) {
|
|
988
|
+
const kind = kindsByName.get(item.kind);
|
|
989
|
+
if (!kind)
|
|
990
|
+
throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
|
991
|
+
const dest = item.destDir;
|
|
992
|
+
// Symlink-escape guard: reject before mkdir if dest (or any component
|
|
993
|
+
// between the install root and dest) is a symlink pointing outside that
|
|
994
|
+
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
|
995
|
+
// call. The install root is normally configDir, but a kind may declare
|
|
996
|
+
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
|
997
|
+
// $HOME/.agents) — in that case the guard must check against the
|
|
998
|
+
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
|
999
|
+
// own root selection in createRuntimeArtifactInstallPlan.
|
|
752
1000
|
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1001
|
+
// #2874: this REFUSAL DECISION stays outside the injected fs adapter —
|
|
1002
|
+
// only hasExistingSymlinkBetween's own existsSync/lstatSync/realpathSync
|
|
1003
|
+
// PROBES are routed through it (install-fs-adapter.cts's module doc).
|
|
1004
|
+
// A fake adapter can change what those probes observe for paths that
|
|
1005
|
+
// were never real to begin with; it cannot make this `if` pass for a
|
|
1006
|
+
// path the real filesystem would refuse.
|
|
1007
|
+
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
1008
|
+
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
|
|
1009
|
+
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
|
|
1010
|
+
// resolved-target-equals-root still refuse regardless of opt-in.
|
|
1011
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
1012
|
+
throw new Error(`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.`);
|
|
1013
|
+
}
|
|
1014
|
+
// #2875 defect fix (--minimal regression closed): a restricted profile
|
|
1015
|
+
// (e.g. --minimal) can legitimately stage ZERO agents — no skill in
|
|
1016
|
+
// the profile's closure references a gsd-* role. The pre-#2875-Part-2
|
|
1017
|
+
// inline agent-staging loop this generic layout loop's agents handling
|
|
1018
|
+
// replaced never created `agents/` at all under a minimal install (the
|
|
1019
|
+
// now-deleted `isMinimalMode` branch skipped the whole step); this
|
|
1020
|
+
// loop's own unconditional `mkdirSync` above regressed that — every
|
|
1021
|
+
// profile, restricted or not, now gets an `agents/` dir materialized
|
|
1022
|
+
// even when nothing will ever be written into it, breaking
|
|
1023
|
+
// `.changeset/zesty-rams-march.md`'s "installed output is
|
|
1024
|
+
// byte-identical to before for every runtime" claim. Restore the old
|
|
1025
|
+
// behavior exactly for the `agents` kind specifically (skills/commands
|
|
1026
|
+
// are unaffected — they are never legitimately empty): skip creating
|
|
1027
|
+
// `dest` (and pruning/copying into it) entirely when this kind's
|
|
1028
|
+
// already-staged `item.sourceDir` (built by createRuntimeArtifactInstallPlan
|
|
1029
|
+
// BEFORE this loop) has nothing in it.
|
|
1030
|
+
if (kind.kind === 'agents') {
|
|
1031
|
+
const stagedAgentFiles = installFs().existsSync(item.sourceDir)
|
|
1032
|
+
? installFs().readdirSync(item.sourceDir).filter((f) => f.endsWith('.md'))
|
|
1033
|
+
: [];
|
|
1034
|
+
// #2875 defect fix, corrected: the ORIGINAL fix (see the comment
|
|
1035
|
+
// above `installAgentsKindStandalone`) skipped this kind's stale-
|
|
1036
|
+
// agent prune along with the write whenever a restricted profile
|
|
1037
|
+
// (e.g. --minimal) staged zero agents — that also skipped
|
|
1038
|
+
// `_removeGsdEntries`, so a full -> minimal downgrade left every
|
|
1039
|
+
// previously-installed gsd-*.md/.toml agent file in place. The
|
|
1040
|
+
// deleted pre-#2875 inline loop never did that: its stale-cleanup
|
|
1041
|
+
// pre-pass ran UNCONDITIONALLY, and only the *write* of new agent
|
|
1042
|
+
// files was gated on minimal mode. Restore that split here: prune
|
|
1043
|
+
// first (no-ops via `_removeGsdEntries`'s own existsSync check when
|
|
1044
|
+
// `dest` was never created, so a fresh install with nothing staged
|
|
1045
|
+
// still never creates it below), then skip mkdir/copy when there is
|
|
1046
|
+
// nothing to write.
|
|
1047
|
+
_removeGsdEntries(dest, kind);
|
|
1048
|
+
if (stagedAgentFiles.length === 0) {
|
|
1049
|
+
continue;
|
|
770
1050
|
}
|
|
771
1051
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1052
|
+
installFs().mkdirSync(dest, { recursive: true });
|
|
1053
|
+
const preserved = [];
|
|
1054
|
+
if (kind.kind === 'skills' && installFs().existsSync(dest)) {
|
|
1055
|
+
// Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
|
|
1056
|
+
// then restore after. This preserves user dirs across a wipe-and-replace
|
|
1057
|
+
// install (#2973 / #3664).
|
|
1058
|
+
//
|
|
1059
|
+
// All runtimes (incl. Hermes after #947) use prefix='gsd-'.
|
|
1060
|
+
// _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
|
|
1061
|
+
// untouched. Preserve the explicit user-owned GSD-prefixed skill
|
|
1062
|
+
// gsd-dev-preferences, which GSD does not reinstall from source but must
|
|
1063
|
+
// survive the prune (#2973).
|
|
1064
|
+
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
1065
|
+
{
|
|
1066
|
+
// Preserve explicitly user-owned GSD-prefixed skill dirs.
|
|
1067
|
+
// gsd-dev-preferences is the sole user-customisable skill in this category.
|
|
1068
|
+
const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
|
|
1069
|
+
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
1070
|
+
const skillDir = node_path_1.default.join(dest, dirName);
|
|
1071
|
+
if (!installFs().existsSync(skillDir))
|
|
1072
|
+
continue;
|
|
1073
|
+
const snap = _snapshotDir(skillDir);
|
|
1074
|
+
if (snap.size > 0)
|
|
1075
|
+
toPreserve.set(dirName, snap);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
_removeGsdEntries(dest, kind);
|
|
1079
|
+
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
|
1080
|
+
// Restore user-owned dirs after the prune+copy
|
|
1081
|
+
for (const [dirName, snap] of toPreserve) {
|
|
1082
|
+
_restoreDir(node_path_1.default.join(dest, dirName), snap);
|
|
1083
|
+
preserved.push(dirName);
|
|
1084
|
+
}
|
|
777
1085
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1086
|
+
else {
|
|
1087
|
+
// For non-skills kinds (commands, agents): no user content to preserve;
|
|
1088
|
+
// just prune stale gsd-* entries and copy new ones.
|
|
1089
|
+
_removeGsdEntries(dest, kind);
|
|
1090
|
+
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
|
|
1091
|
+
}
|
|
1092
|
+
executedKinds.push({ kind: item.kind, sourceDir: item.sourceDir, destDir: dest, preserved });
|
|
784
1093
|
}
|
|
785
1094
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1095
|
+
finally {
|
|
1096
|
+
// #2874 rows 10/11: cleanup stays best-effort (an install must never
|
|
1097
|
+
// fail on cleanup) but a failed rmSync is now VISIBLE in `cleanup`
|
|
1098
|
+
// rather than silently swallowed — silently absent is worse than the
|
|
1099
|
+
// `void` return this replaces (40-design.md negative-space section).
|
|
1100
|
+
for (const dir of cleanupDirs) {
|
|
1101
|
+
try {
|
|
1102
|
+
installFs().rmSync(dir, { recursive: true, force: true });
|
|
1103
|
+
cleanupResults.push({ dir, ok: true });
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
cleanupResults.push({ dir, ok: false });
|
|
1107
|
+
}
|
|
791
1108
|
}
|
|
792
|
-
catch { /* best-effort */ }
|
|
793
1109
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1110
|
+
// Hermes: after the install loop has written all gsd-<stem>/ dirs to
|
|
1111
|
+
// skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
|
|
1112
|
+
// correspond to the newly installed gsd-<stem> entries. This is the robust
|
|
1113
|
+
// replacement for the readGsdCommandNames()-based pre-install cleanup that
|
|
1114
|
+
// missed skills like 'dev-preferences' (#947 adversarial review).
|
|
1115
|
+
//
|
|
1116
|
+
// We run this AFTER the install loop so the installed set is authoritative:
|
|
1117
|
+
// every gsd-<stem>/ present now was written this run (or was there before
|
|
1118
|
+
// with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
|
|
1119
|
+
// are untouched.
|
|
1120
|
+
let hermesBareStemCleanup = false;
|
|
1121
|
+
if (runtime === 'hermes') {
|
|
1122
|
+
const nestedGsdDirForCleanup = node_path_1.default.join(configDir, 'skills', 'gsd');
|
|
1123
|
+
_removeHermesBareStemDirs(nestedGsdDirForCleanup);
|
|
1124
|
+
hermesBareStemCleanup = true;
|
|
1125
|
+
}
|
|
1126
|
+
// Generic-branch nativePlugin staging (ADR-1239 / #2102 Stage 1): runtimes
|
|
1127
|
+
// outside the OpenCode/Kilo combined-family install (e.g. pi, whose
|
|
1128
|
+
// artifactLayout is empty and which never sets combinedFamilyInstall) still
|
|
1129
|
+
// need their declared hostBehaviors.nativePlugin file copied into configDir.
|
|
1130
|
+
// findInstallSourceRoot resolves the repo/package root independent of
|
|
1131
|
+
// configDir contents (marker check, then a walk-up from __dirname), so this
|
|
1132
|
+
// is safe even when configDir has no .gsd-source marker (artifactLayout: []).
|
|
1133
|
+
let nativePluginInstalled = false;
|
|
1134
|
+
if (behaviors.nativePlugin) {
|
|
1135
|
+
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
|
1136
|
+
const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
|
|
1137
|
+
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
|
1138
|
+
nativePluginInstalled = true;
|
|
1139
|
+
}
|
|
1140
|
+
// #2874 row 14: an empty `layout.kinds` still returns `kinds: []` here
|
|
1141
|
+
// (executedKinds was never mutated), never `undefined`.
|
|
1142
|
+
return {
|
|
1143
|
+
runtime,
|
|
1144
|
+
scope,
|
|
1145
|
+
kinds: executedKinds,
|
|
1146
|
+
cleanup: cleanupResults,
|
|
1147
|
+
postSteps: { hermesBareStemCleanup, nativePlugin: nativePluginInstalled },
|
|
1148
|
+
};
|
|
1149
|
+
});
|
|
821
1150
|
}
|
|
822
1151
|
// ---------------------------------------------------------------------------
|
|
823
1152
|
// installOpencodeFamilySkills
|
|
@@ -855,7 +1184,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
855
1184
|
if (!skillsKindEntry)
|
|
856
1185
|
return 0;
|
|
857
1186
|
const rawDir = rawCommandsDir;
|
|
858
|
-
if (!rawDir || !
|
|
1187
|
+
if (!rawDir || !installFs().existsSync(rawDir))
|
|
859
1188
|
return 0;
|
|
860
1189
|
// #2093: descriptor-driven — dispatch off the skills-kind entry's `converter`
|
|
861
1190
|
// string (capabilities/<runtime>/capability.json artifactLayout) via the
|
|
@@ -881,7 +1210,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
881
1210
|
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
882
1211
|
throw new Error(`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.`);
|
|
883
1212
|
}
|
|
884
|
-
|
|
1213
|
+
installFs().mkdirSync(dest, { recursive: true });
|
|
885
1214
|
// Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
|
|
886
1215
|
// gsd-dev-preferences is generated by the user (via generate-dev-preferences)
|
|
887
1216
|
// and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
|
|
@@ -891,7 +1220,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
891
1220
|
const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
|
|
892
1221
|
for (const dirName of USER_OWNED_SKILL_DIRS) {
|
|
893
1222
|
const skillDir = node_path_1.default.join(dest, dirName);
|
|
894
|
-
if (!
|
|
1223
|
+
if (!installFs().existsSync(skillDir))
|
|
895
1224
|
continue;
|
|
896
1225
|
const snap = _snapshotDir(skillDir);
|
|
897
1226
|
if (snap.size > 0)
|
|
@@ -900,19 +1229,19 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
900
1229
|
_removeGsdEntries(dest, skillsKindEntry);
|
|
901
1230
|
let count = 0;
|
|
902
1231
|
const firstPartyStems = new Set();
|
|
903
|
-
for (const entry of
|
|
1232
|
+
for (const entry of installFs().readdirSync(rawDir, { withFileTypes: true })) {
|
|
904
1233
|
if (!entry.isFile() || !entry.name.endsWith('.md'))
|
|
905
1234
|
continue;
|
|
906
1235
|
const stem = entry.name.slice(0, -3);
|
|
907
1236
|
firstPartyStems.add(stem);
|
|
908
1237
|
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
|
909
|
-
let content =
|
|
1238
|
+
let content = installFs().readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
|
|
910
1239
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
911
1240
|
content = processAttribution(content, resolveAttribution(runtime));
|
|
912
1241
|
content = converter(content, skillName);
|
|
913
1242
|
const skillDir = node_path_1.default.join(dest, skillName);
|
|
914
|
-
|
|
915
|
-
|
|
1243
|
+
installFs().mkdirSync(skillDir, { recursive: true });
|
|
1244
|
+
installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
|
|
916
1245
|
count++;
|
|
917
1246
|
}
|
|
918
1247
|
// #2362: materialize installed THIRD-PARTY capability skills, bound to their
|
|
@@ -950,13 +1279,13 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
950
1279
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
951
1280
|
content = processAttribution(content, resolveAttribution(runtime));
|
|
952
1281
|
const skillDir = node_path_1.default.join(dest, skillName);
|
|
953
|
-
|
|
954
|
-
|
|
1282
|
+
installFs().mkdirSync(skillDir, { recursive: true });
|
|
1283
|
+
installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
|
|
955
1284
|
// #2322 HIGH-3 parity: persist the capability-owned marker so a later
|
|
956
1285
|
// prune pass can identify this directory even once the owning
|
|
957
1286
|
// capability is uninstalled/unsurfaced and no longer appears in any
|
|
958
1287
|
// registry view.
|
|
959
|
-
|
|
1288
|
+
installFs().writeFileSync(node_path_1.default.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
|
960
1289
|
count++;
|
|
961
1290
|
}
|
|
962
1291
|
}
|
|
@@ -967,6 +1296,93 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
967
1296
|
return count;
|
|
968
1297
|
}
|
|
969
1298
|
// ---------------------------------------------------------------------------
|
|
1299
|
+
// installAgentsKindStandalone
|
|
1300
|
+
// ---------------------------------------------------------------------------
|
|
1301
|
+
/**
|
|
1302
|
+
* Install the descriptor-driven `agents` kind for a runtime OUTSIDE the
|
|
1303
|
+
* generic `installRuntimeArtifacts` layout loop — i.e. any runtime/scope
|
|
1304
|
+
* combination that never reaches that loop's own `layout.kinds` iteration.
|
|
1305
|
+
* Two such call sites exist (#2875 Part 2):
|
|
1306
|
+
*
|
|
1307
|
+
* 1. **OpenCode-family runtimes** (OpenCode/Kilo, Task A) — `hostBehaviors.
|
|
1308
|
+
* combinedFamilyInstall` makes `installRuntimeArtifacts` early-return into
|
|
1309
|
+
* `installOpencodeFamilyArtifacts` instead, which stages commands+skills
|
|
1310
|
+
* via its OWN bespoke writers and never called `resolveRuntimeArtifactLayout`
|
|
1311
|
+
* for agents at all before this function existed. Declaring a
|
|
1312
|
+
* `capability.json` `agents` entry for them without this would be inert
|
|
1313
|
+
* on the real install path while live on `/gsd:surface` (#1879-F15).
|
|
1314
|
+
* 2. **Claude local** (`bin/install.js`'s `install()`, `_isSkillsRuntime ===
|
|
1315
|
+
* false` branch) — `hostBehaviors.localInstallStyle === 'legacy-flat'`
|
|
1316
|
+
* routes claude-local's commands/skills through `copyWithPathReplacement`
|
|
1317
|
+
* instead of the layout loop, so it never reached `installRuntimeArtifacts`
|
|
1318
|
+
* either. Its agents were previously written ONLY by the now-deleted
|
|
1319
|
+
* inline agent-staging loop (Task C) — deleting that loop without this
|
|
1320
|
+
* call site regressed claude-local's agents/ to empty (caught by the
|
|
1321
|
+
* install-tree golden fixture, `tests/fixtures/install-tree/claude-local.json`).
|
|
1322
|
+
*
|
|
1323
|
+
* Reuses the SAME descriptor path every runtime inside the generic loop uses
|
|
1324
|
+
* (`layout.kinds` → `agentsKindEntry.stage(resolvedProfile, agentCtx)` →
|
|
1325
|
+
* `_copyStaged`), rather than forking a second agent-staging pipeline. A
|
|
1326
|
+
* runtime/scope whose resolved layout declares no `agents` kind at all
|
|
1327
|
+
* (e.g. pi, whose `artifactLayout` is empty for both scopes) is a no-op
|
|
1328
|
+
* (`null`) — mirrors `installOpencodeFamilySkills`'s own
|
|
1329
|
+
* `if (!skillsKindEntry) return 0` contract.
|
|
1330
|
+
*
|
|
1331
|
+
* @param runtime - canonical runtime id
|
|
1332
|
+
* @param targetDir - resolved runtime config directory
|
|
1333
|
+
* @param scope - install scope ('global' | 'local')
|
|
1334
|
+
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
|
1335
|
+
* @param pathPrefix - computed config-path prefix for body rewrites (ADR-1235 §1 agentCtx)
|
|
1336
|
+
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
1337
|
+
* @param capabilityRegistry - #2362: optional composed capability registry, threaded
|
|
1338
|
+
* straight through to resolveRuntimeArtifactLayout (unused by the agents kind today,
|
|
1339
|
+
* but kept for signature parity with the skills/commands siblings on this call tree)
|
|
1340
|
+
* @returns `{ sourceDir, destDir }` describing what was written, or `null` when the
|
|
1341
|
+
* runtime's layout declares no `agents` kind.
|
|
1342
|
+
*/
|
|
1343
|
+
function installAgentsKindStandalone(runtime, targetDir, scope, resolvedProfile, pathPrefix, resolveAttribution = () => undefined, capabilityRegistry) {
|
|
1344
|
+
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir, scope, capabilityRegistry);
|
|
1345
|
+
const agentsKindEntry = layout.kinds.find((k) => k.kind === 'agents');
|
|
1346
|
+
if (!agentsKindEntry)
|
|
1347
|
+
return null;
|
|
1348
|
+
// ADR-1235 §1: same agentCtx shape createRuntimeArtifactInstallPlan builds
|
|
1349
|
+
// for the generic layout-driven loop (runtime-artifact-install-plan.cts) —
|
|
1350
|
+
// targetDir IS the install root the inline agent loop called `targetDir`.
|
|
1351
|
+
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
|
1352
|
+
const agentCtx = { runtime, pathPrefix, attribution, targetDir };
|
|
1353
|
+
const stagedDir = agentsKindEntry.stage(resolvedProfile, agentCtx);
|
|
1354
|
+
const stagedAgentFiles = installFs().existsSync(stagedDir)
|
|
1355
|
+
? installFs().readdirSync(stagedDir).filter((f) => f.endsWith('.md'))
|
|
1356
|
+
: [];
|
|
1357
|
+
const installRoot = (typeof agentsKindEntry.home === 'string' && agentsKindEntry.home !== '') ? agentsKindEntry.home : targetDir;
|
|
1358
|
+
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, agentsKindEntry.destSubpath);
|
|
1359
|
+
// Symlink-escape guard — same gate _copyStaged/installOpencodeFamilySkills apply
|
|
1360
|
+
// to their own writes (#2393 GSD_ALLOW_SYMLINKED_DEST opt-in preserved). Runs
|
|
1361
|
+
// even when nothing will be written this call — the stale-agent prune below
|
|
1362
|
+
// (`_removeGsdEntries`) still touches `dest` whenever it already exists.
|
|
1363
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
|
|
1364
|
+
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.`);
|
|
1365
|
+
}
|
|
1366
|
+
// #2875 defect fix, corrected: the ORIGINAL fix returned `null` (no-op)
|
|
1367
|
+
// whenever a restricted profile (e.g. --minimal) staged ZERO agents,
|
|
1368
|
+
// which — because that early return sat ABOVE the prune call — also
|
|
1369
|
+
// skipped `_removeGsdEntries`, leaving every previously-installed
|
|
1370
|
+
// gsd-*.md/.toml agent file in place on a full -> minimal downgrade. The
|
|
1371
|
+
// deleted pre-#2875 inline loop never did that: its stale-cleanup pre-pass
|
|
1372
|
+
// ran UNCONDITIONALLY (removing gsd-*.md, plus .toml for codex), and only
|
|
1373
|
+
// the *write* of new agent files was gated on minimal mode. Restore that
|
|
1374
|
+
// split: prune first — a no-op via `_removeGsdEntries`'s own existsSync
|
|
1375
|
+
// check when `dest` was never created, so a fresh install with nothing
|
|
1376
|
+
// staged still never creates it below — then skip mkdir/copy (and return
|
|
1377
|
+
// `null`, matching the doc comment above) when there is nothing to write.
|
|
1378
|
+
_removeGsdEntries(dest, agentsKindEntry);
|
|
1379
|
+
if (stagedAgentFiles.length === 0)
|
|
1380
|
+
return null;
|
|
1381
|
+
installFs().mkdirSync(dest, { recursive: true });
|
|
1382
|
+
_copyStaged(stagedDir, dest, agentsKindEntry, targetDir, runtime);
|
|
1383
|
+
return { sourceDir: stagedDir, destDir: dest };
|
|
1384
|
+
}
|
|
1385
|
+
// ---------------------------------------------------------------------------
|
|
970
1386
|
// installOpencodeFamilyCommands
|
|
971
1387
|
// ---------------------------------------------------------------------------
|
|
972
1388
|
/**
|
|
@@ -986,19 +1402,19 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
986
1402
|
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
|
|
987
1403
|
*/
|
|
988
1404
|
function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, resolveAttribution = () => undefined, prefix = 'gsd') {
|
|
989
|
-
if (!
|
|
1405
|
+
if (!installFs().existsSync(srcDir))
|
|
990
1406
|
return;
|
|
991
1407
|
// Remove old gsd-*.md files before copying new ones
|
|
992
|
-
if (
|
|
993
|
-
for (const file of
|
|
1408
|
+
if (installFs().existsSync(destDir)) {
|
|
1409
|
+
for (const file of installFs().readdirSync(destDir)) {
|
|
994
1410
|
if (file.startsWith(`${prefix}-`) && file.endsWith('.md'))
|
|
995
|
-
|
|
1411
|
+
installFs().unlinkSync(node_path_1.default.join(destDir, file));
|
|
996
1412
|
}
|
|
997
1413
|
}
|
|
998
1414
|
else {
|
|
999
|
-
|
|
1415
|
+
installFs().mkdirSync(destDir, { recursive: true });
|
|
1000
1416
|
}
|
|
1001
|
-
for (const entry of
|
|
1417
|
+
for (const entry of installFs().readdirSync(srcDir, { withFileTypes: true })) {
|
|
1002
1418
|
const srcPath = node_path_1.default.join(srcDir, entry.name);
|
|
1003
1419
|
if (entry.isDirectory()) {
|
|
1004
1420
|
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
|
|
@@ -1006,7 +1422,7 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
|
|
|
1006
1422
|
else if (entry.name.endsWith('.md')) {
|
|
1007
1423
|
const baseName = entry.name.replace('.md', '');
|
|
1008
1424
|
const destName = `${prefix}-${baseName}.md`;
|
|
1009
|
-
let content =
|
|
1425
|
+
let content = installFs().readFileSync(srcPath, 'utf8');
|
|
1010
1426
|
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
1011
1427
|
content = processAttribution(content, resolveAttribution(runtime));
|
|
1012
1428
|
// #2093: this commands-kind entry's descriptor `converter` field is
|
|
@@ -1022,7 +1438,7 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
|
|
|
1022
1438
|
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
|
1023
1439
|
? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
|
|
1024
1440
|
: runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
|
|
1025
|
-
|
|
1441
|
+
installFs().writeFileSync(node_path_1.default.join(destDir, destName), content);
|
|
1026
1442
|
}
|
|
1027
1443
|
}
|
|
1028
1444
|
}
|
|
@@ -1050,7 +1466,7 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
|
|
|
1050
1466
|
const np = behaviors.nativePlugin;
|
|
1051
1467
|
if (np && np.source) {
|
|
1052
1468
|
const pluginSrc = node_path_1.default.join(src, np.source);
|
|
1053
|
-
if (
|
|
1469
|
+
if (installFs().existsSync(pluginSrc)) {
|
|
1054
1470
|
// Confine the FULL dest path (dir + file), not just the dir. Previously
|
|
1055
1471
|
// only `np.dir` was validated and `np.file` was joined on unchecked, so a
|
|
1056
1472
|
// descriptor whose `file` carried `..`, an absolute path, or a NUL byte
|
|
@@ -1060,8 +1476,8 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
|
|
|
1060
1476
|
// nothing. For a well-formed descriptor this resolves identically to the
|
|
1061
1477
|
// previous mkdir(dir) + join(dir, file).
|
|
1062
1478
|
const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.join(np.dir, np.file));
|
|
1063
|
-
|
|
1064
|
-
|
|
1479
|
+
installFs().mkdirSync(node_path_1.default.dirname(destPath), { recursive: true });
|
|
1480
|
+
installFs().copyFileSync(pluginSrc, destPath);
|
|
1065
1481
|
// #2544: the staged adapter is a `.js` file, so Node decides its module
|
|
1066
1482
|
// type by walking up for the nearest package.json. It used to find the
|
|
1067
1483
|
// marker the installer wrote at the config root — the write that
|
|
@@ -1130,15 +1546,24 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
|
|
|
1130
1546
|
if (currentName === LEGACY_NAME)
|
|
1131
1547
|
return; // e.g. Kilo — legacy IS the current location; nothing to migrate
|
|
1132
1548
|
const legacyDir = node_path_1.default.join(configDir, LEGACY_NAME);
|
|
1133
|
-
if (!
|
|
1549
|
+
if (!installFs().existsSync(legacyDir))
|
|
1134
1550
|
return;
|
|
1135
1551
|
// Never follow a symlinked legacy dir out of configDir.
|
|
1136
|
-
if (
|
|
1552
|
+
if (installFs().lstatSync(legacyDir).isSymbolicLink())
|
|
1137
1553
|
return;
|
|
1554
|
+
// #2874: installerMigrations.readInstallManifest/classifyArtifact are
|
|
1555
|
+
// routed through the injectable seam (installer-migrations.cts:36,54-58,
|
|
1556
|
+
// 376-380 — readInstallManifest -> readJsonIfPresent -> installFs(),
|
|
1557
|
+
// classifyArtifact -> sha256File -> installFs().readFileSync), so a
|
|
1558
|
+
// fake-adapter install of an opencode-family runtime with a legacy
|
|
1559
|
+
// `command/` dir present reaches the fake, not real fs. Exercised by
|
|
1560
|
+
// tests/executed-plan.test.cjs's F2 "opencode-family legacy command/ dir
|
|
1561
|
+
// migration" case, which poisons every real fs method and asserts the
|
|
1562
|
+
// fake store was mutated.
|
|
1138
1563
|
const manifest = installerMigrations.readInstallManifest(configDir);
|
|
1139
1564
|
let entries;
|
|
1140
1565
|
try {
|
|
1141
|
-
entries =
|
|
1566
|
+
entries = installFs().readdirSync(legacyDir, { withFileTypes: true });
|
|
1142
1567
|
}
|
|
1143
1568
|
catch {
|
|
1144
1569
|
return;
|
|
@@ -1152,7 +1577,7 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
|
|
|
1152
1577
|
const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest);
|
|
1153
1578
|
if (classification === 'managed-pristine' || classification === 'managed-modified') {
|
|
1154
1579
|
try {
|
|
1155
|
-
|
|
1580
|
+
installFs().unlinkSync(node_path_1.default.join(legacyDir, entry.name));
|
|
1156
1581
|
}
|
|
1157
1582
|
catch { /* best-effort */ }
|
|
1158
1583
|
}
|
|
@@ -1160,8 +1585,8 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
|
|
|
1160
1585
|
// ownership, so it must never be deleted as collateral damage.
|
|
1161
1586
|
}
|
|
1162
1587
|
try {
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
1588
|
+
if (installFs().readdirSync(legacyDir).length === 0)
|
|
1589
|
+
installFs().rmdirSync(legacyDir);
|
|
1165
1590
|
}
|
|
1166
1591
|
catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ }
|
|
1167
1592
|
}
|
|
@@ -1186,9 +1611,18 @@ function _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors) {
|
|
|
1186
1611
|
* installOpencodeFamilySkills so an installed third-party capability skill
|
|
1187
1612
|
* materializes for this combined-family (OpenCode/Kilo) install path too.
|
|
1188
1613
|
* Absent -> no third-party skills staged (fail closed).
|
|
1614
|
+
* @returns #2874 design row 2: an executed-plan value, same top-level shape
|
|
1615
|
+
* (`runtime`/`scope`/`kinds`/`cleanup`/`postSteps`) as the generic
|
|
1616
|
+
* `installRuntimeArtifacts` branch — this was the one early return a
|
|
1617
|
+
* `void`-shaped hole survived unnoticed in.
|
|
1189
1618
|
*/
|
|
1190
1619
|
function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}, capabilityRegistry) {
|
|
1191
|
-
|
|
1620
|
+
// #2870: `scope` keeps its exported required `string` signature (no
|
|
1621
|
+
// signature change). It is always the `installRuntimeArtifacts`-forwarded
|
|
1622
|
+
// 'global' | 'local' literal produced by bin/install.js's scope-resolution
|
|
1623
|
+
// ternary (both real call sites and every test call site), so the cast is
|
|
1624
|
+
// safe: `isGlobalScope` never sees a value outside its union here.
|
|
1625
|
+
const isGlobal = (0, install_scope_cjs_1.isGlobalScope)(scope);
|
|
1192
1626
|
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
|
1193
1627
|
// (via the .gsd-source marker or a walk-up from __dirname) — every other
|
|
1194
1628
|
// call site in runtime-artifact-layout.cts feeds its return value straight
|
|
@@ -1213,8 +1647,29 @@ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfi
|
|
|
1213
1647
|
// keeps its own descriptor value ('command', singular) unchanged.
|
|
1214
1648
|
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, behaviors.flatCommandDir || 'command');
|
|
1215
1649
|
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
1216
|
-
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
|
1650
|
+
const skillsWritten = installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
|
1651
|
+
// #2875 Part 2 Task A: agents kind, reusing the SAME descriptor path the
|
|
1652
|
+
// generic layout-driven loop uses (see installAgentsKindStandalone's own
|
|
1653
|
+
// doc). A `null` result means this runtime's layout declares no `agents`
|
|
1654
|
+
// kind — nothing written, nothing reported (no #1879-F15 inert claim).
|
|
1655
|
+
const agentsResult = installAgentsKindStandalone(runtime, configDir, scope, resolvedProfile, pathPrefix, resolveAttribution, capabilityRegistry);
|
|
1217
1656
|
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
|
1657
|
+
// #2874 design row 2: report what this combined-family install wrote,
|
|
1658
|
+
// mirroring the generic branch's top-level shape. `cleanup` is `[]` — this
|
|
1659
|
+
// path stages via install-profiles.cts's STAGED_DIRS (process-exit
|
|
1660
|
+
// cleanup), not the per-call cleanupDirs mechanism createRuntimeArtifactInstallPlan
|
|
1661
|
+
// uses, so there is nothing this call itself attempted to clean up.
|
|
1662
|
+
return {
|
|
1663
|
+
runtime,
|
|
1664
|
+
scope,
|
|
1665
|
+
kinds: [
|
|
1666
|
+
{ kind: 'commands', sourceDir: rawCommandsDir, destDir: commandDir },
|
|
1667
|
+
{ kind: 'skills', sourceDir: rawCommandsDir, destDir: configDir, written: skillsWritten },
|
|
1668
|
+
...(agentsResult ? [{ kind: 'agents', sourceDir: agentsResult.sourceDir, destDir: agentsResult.destDir }] : []),
|
|
1669
|
+
],
|
|
1670
|
+
cleanup: [],
|
|
1671
|
+
postSteps: { hermesBareStemCleanup: false, nativePlugin: Boolean(behaviors.nativePlugin) },
|
|
1672
|
+
};
|
|
1218
1673
|
}
|
|
1219
1674
|
// ---------------------------------------------------------------------------
|
|
1220
1675
|
// uninstallRuntimeArtifacts
|
|
@@ -1236,9 +1691,9 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
|
|
|
1236
1691
|
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(runtime, configDir);
|
|
1237
1692
|
// Legacy cleanup before layout-driven removal (scope-aware to avoid
|
|
1238
1693
|
// removing Claude local commands/gsd/ which is the primary install dir).
|
|
1239
|
-
// Returns
|
|
1694
|
+
// Returns staged user artifacts so we can migrate AFTER layout removal
|
|
1240
1695
|
// (the layout's gsd-* prefix pass would wipe a skill dir created here).
|
|
1241
|
-
const
|
|
1696
|
+
const stagedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
|
|
1242
1697
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
1243
1698
|
const plan = runtimeArtifactInstallPlan.createRuntimeArtifactUninstallPlan(layout);
|
|
1244
1699
|
const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
|
|
@@ -1268,8 +1723,39 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
|
|
|
1268
1723
|
// #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
|
|
1269
1724
|
// runtime-aware SKILL.md location after all layout-driven removal is
|
|
1270
1725
|
// complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
|
|
1271
|
-
if (
|
|
1726
|
+
if (stagedLegacyArtifacts) {
|
|
1727
|
+
// #2875: read the content back from the DISK-staged copy, matching
|
|
1728
|
+
// _runLegacyInstallMigrations's call site — never restored on failure
|
|
1729
|
+
// here either (the user is uninstalling; there is nothing to restore to).
|
|
1730
|
+
//
|
|
1731
|
+
// Security fix (parity with _runLegacyInstallMigrations's own guard,
|
|
1732
|
+
// src/install-engine.cts / bin/install.js:8478): `readFileSync` ALWAYS
|
|
1733
|
+
// follows a symlink. A staged `dev-preferences.md` that is itself a
|
|
1734
|
+
// symlink (user-artifact-staging.cts's "Symlink safety" contract: a
|
|
1735
|
+
// symlinked user artifact is recreated AS a symlink in the staging tree,
|
|
1736
|
+
// never copied by content) would previously have its REFERENT's bytes
|
|
1737
|
+
// read here and land in SKILL.md verbatim — e.g. a symlink to
|
|
1738
|
+
// `~/.ssh/id_rsa` gets its private key content written into a file GSD
|
|
1739
|
+
// loads into agent context. A symlink to a DIRECTORY instead throws
|
|
1740
|
+
// EISDIR uncaught out of this function, which the caller never expected
|
|
1741
|
+
// and which left the staged entry undiscarded (re-materializing on the
|
|
1742
|
+
// next recovery pass and failing uninstall every time thereafter).
|
|
1743
|
+
// lstatSync never follows a symlink; skip a symlinked name entirely
|
|
1744
|
+
// (never migrated) rather than dereferencing it.
|
|
1745
|
+
const savedLegacyArtifacts = new Map();
|
|
1746
|
+
for (const name of stagedLegacyArtifacts.names) {
|
|
1747
|
+
const stagedPath = node_path_1.default.join(stagedLegacyArtifacts.filesDir, name);
|
|
1748
|
+
// #2875 defect fix (crash resilience — TOCTOU, parity with
|
|
1749
|
+
// _runLegacyInstallMigrations's own fix above): a raw `lstatSync`
|
|
1750
|
+
// throws if `stagedPath` has vanished between staging and this read;
|
|
1751
|
+
// degrade via `tryLstat` instead of crashing uninstall.
|
|
1752
|
+
const stagedLstat = tryLstat(stagedPath);
|
|
1753
|
+
if (!stagedLstat || stagedLstat.isSymbolicLink())
|
|
1754
|
+
continue;
|
|
1755
|
+
savedLegacyArtifacts.set(name, installFs().readFileSync(stagedPath, 'utf8'));
|
|
1756
|
+
}
|
|
1272
1757
|
migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
|
|
1758
|
+
userArtifactStaging.discardStagedUserArtifacts(stagedLegacyArtifacts);
|
|
1273
1759
|
}
|
|
1274
1760
|
}
|
|
1275
1761
|
module.exports = {
|
|
@@ -1277,14 +1763,15 @@ module.exports = {
|
|
|
1277
1763
|
uninstallRuntimeArtifacts,
|
|
1278
1764
|
installOpencodeFamilySkills,
|
|
1279
1765
|
installOpencodeFamilyCommands,
|
|
1766
|
+
installAgentsKindStandalone,
|
|
1280
1767
|
installOpencodeFamilyArtifacts,
|
|
1281
1768
|
_installNativePluginIfDeclared,
|
|
1282
1769
|
_hostBehaviors,
|
|
1283
1770
|
_copyStaged,
|
|
1284
1771
|
hasExistingSymlinkBetween,
|
|
1285
1772
|
isSymlinkedDestOptIn,
|
|
1286
|
-
|
|
1287
|
-
|
|
1773
|
+
_resolveUserArtifactStagingRoot,
|
|
1774
|
+
_tryResolveUserArtifactStagingRoot,
|
|
1288
1775
|
migrateLegacyDevPreferencesToSkill,
|
|
1289
1776
|
applyOpencodeFamilyPathPrefix,
|
|
1290
1777
|
convertClaudeCommandToOpencodeSkill,
|