@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
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* User Artifact Staging Module — #2875 (epic #2866 Phase 6), governed by
|
|
7
|
+
* ADR-3574 and `.gsd/phase/feat-2875-materialization-primitives/40-design.md`
|
|
8
|
+
* Part 1.
|
|
9
|
+
*
|
|
10
|
+
* Durable, on-disk staging for user-owned artifacts (`USER_OWNED_ARTIFACTS`,
|
|
11
|
+
* `install-engine.cts`) across the preserve → wipe → restore window that
|
|
12
|
+
* `preserveUserArtifacts`/`restoreUserArtifacts` previously held only in an
|
|
13
|
+
* in-memory `Map` (#1874-F19). A process death between preserve and restore —
|
|
14
|
+
* Ctrl-C, OOM, a converter throw mid-copy — silently discarded the map,
|
|
15
|
+
* losing the user's file forever. Staging to disk closes that window;
|
|
16
|
+
* `recoverOrphanedUserArtifacts` closes the OTHER half of the fix: a staged
|
|
17
|
+
* copy nothing ever reads back is bytes-safe but user-visibly lost — the
|
|
18
|
+
* #1879-F15 inert-fix failure mode 40-design.md's "inertness trap" section
|
|
19
|
+
* names explicitly. See that doc for the full rationale.
|
|
20
|
+
*
|
|
21
|
+
* Synchronous only, matching install-fs-adapter.cts's documented contract —
|
|
22
|
+
* every fs call in this module routes through `installFs()`.
|
|
23
|
+
*
|
|
24
|
+
* Staging layout, all under a caller-supplied `stagingRoot` (by convention
|
|
25
|
+
* `<configDir>/.gsd-staging/user-artifacts/` — a sibling of every wipe
|
|
26
|
+
* target this phase's call sites wipe, so staging survives all of them,
|
|
27
|
+
* while still resolving inside `configDir`):
|
|
28
|
+
*
|
|
29
|
+
* <stagingRoot>/<sha256(destDir)-16>-<sha256(runId)-8>/record.json — the commit point
|
|
30
|
+
* <stagingRoot>/<sha256(destDir)-16>-<sha256(runId)-8>/files/<name> — copied, symlink-safe
|
|
31
|
+
*
|
|
32
|
+
* The sha256-of-destDir component (not a raw path) keeps the entry-dir name
|
|
33
|
+
* filesystem-safe; the sha256-of-runId component (`opts.runId`, default
|
|
34
|
+
* `String(process.pid)`) discriminates concurrent RUNS targeting the same
|
|
35
|
+
* destDir (module doc "Concurrency" below) while still making repeat calls
|
|
36
|
+
* from the SAME run (the same process, the default case) reuse/overwrite the
|
|
37
|
+
* same entry rather than accumulating orphans (test-matrix A6) — hashing
|
|
38
|
+
* `runId` rather than using it raw keeps the same filesystem-safety/bounded-
|
|
39
|
+
* length guarantee the destDir hash already provides, regardless of what a
|
|
40
|
+
* caller passes.
|
|
41
|
+
*
|
|
42
|
+
* `record.json` (`{ destDir, names, timestamp }`) is written AFTER every
|
|
43
|
+
* file copy lands, never before — a half-written staging directory (a crash
|
|
44
|
+
* during the copy loop) has no record, and `recoverOrphanedUserArtifacts`
|
|
45
|
+
* ignores it (test-matrix B4/A7). The record's PRESENCE is what "staged"
|
|
46
|
+
* means; this module never infers completeness from directory contents
|
|
47
|
+
* alone.
|
|
48
|
+
*
|
|
49
|
+
* Confinement: this module carries the SAME "no policy, reuse the existing
|
|
50
|
+
* decision" discipline install-fs-adapter.cts documents for
|
|
51
|
+
* `hasExistingSymlinkBetween` / `assertDestWithinConfigHome` — it never
|
|
52
|
+
* reimplements either. Every path this module writes, and every path
|
|
53
|
+
* `recoverOrphanedUserArtifacts` reads OUT of an on-disk record before
|
|
54
|
+
* writing to it (attacker-influenceable input the moment an install runs on
|
|
55
|
+
* a shared machine — test-matrix E2), is re-resolved through the SAME
|
|
56
|
+
* `assertDestWithinConfigHome` (runtime-artifact-install-plan.cts) every
|
|
57
|
+
* other write on this call tree already uses, never a bespoke check.
|
|
58
|
+
* `recoverOrphanedUserArtifacts` ADDITIONALLY re-applies `hasExistingSymlinkBetween`
|
|
59
|
+
* (install-engine.cts) — the SAME guard `_copyStaged`/
|
|
60
|
+
* `migrateLegacyDevPreferencesToSkill` apply to their own writes — against
|
|
61
|
+
* the record's `destDir` once it has cleared `assertDestWithinConfigHome`
|
|
62
|
+
* (#2875 defect fix, test-matrix E2 strengthened): lexical confinement alone
|
|
63
|
+
* does not detect a symlinked ANCESTOR directory (e.g. `<configDir>/linkdir
|
|
64
|
+
* -> <outside>`, a record naming `<configDir>/linkdir/sub/USER-PROFILE.md`)
|
|
65
|
+
* — `path.resolve` string math has no concept of what a path component
|
|
66
|
+
* actually IS on disk. `hasExistingSymlinkBetween` is required lazily
|
|
67
|
+
* (inside the function body, not at module top) via `require('./install-
|
|
68
|
+
* engine.cjs')` specifically to avoid a load-time circular require:
|
|
69
|
+
* install-engine.cts imports this module statically at its own top, so a
|
|
70
|
+
* static top-level import here would capture install-engine's exports
|
|
71
|
+
* object BEFORE its own `export =` assignment runs, permanently binding to
|
|
72
|
+
* an empty object (the classic `module.exports = {...}` circular-require
|
|
73
|
+
* footgun) — a lazy, call-time `require` instead resolves against the fully
|
|
74
|
+
* populated module, exactly matching the existing lazy-require precedent
|
|
75
|
+
* `runtime-artifact-install-plan.cts` already uses for the same reason. This
|
|
76
|
+
* module does not accept a `configDir` parameter to `stageUserArtifacts` (it
|
|
77
|
+
* cannot confine `stagingRoot` itself against a configHome — callers remain
|
|
78
|
+
* responsible for that; the same call-site pattern `_copyStaged`/
|
|
79
|
+
* `migrateLegacyDevPreferencesToSkill` already use for
|
|
80
|
+
* `hasExistingSymlinkBetween`, including the symlinked-staging-root refusal,
|
|
81
|
+
* test-matrix E4 — see install-engine.cts's and bin/install.js's call sites).
|
|
82
|
+
* `recoverOrphanedUserArtifacts`, however, DOES take `configDir` explicitly —
|
|
83
|
+
* deriving it from `stagingRoot`'s own path shape would rest the E2
|
|
84
|
+
* confinement guarantee on a naming convention rather than an explicit
|
|
85
|
+
* caller-supplied value, which is fragile in exactly the direction E2 exists
|
|
86
|
+
* to guard against.
|
|
87
|
+
*
|
|
88
|
+
* Never-overwrite (C2) and destination-symlink refusal: both
|
|
89
|
+
* `recoverOrphanedUserArtifacts` and `restoreStagedUserArtifacts` probe the
|
|
90
|
+
* destination with `lstatSync` (never `existsSync`) before writing (#2875
|
|
91
|
+
* defect fix). `existsSync` FOLLOWS symlinks and reports `false` for a
|
|
92
|
+
* DANGLING one — a symlink whose target does not exist — so an
|
|
93
|
+
* `existsSync`-based "is something already there" check is blind to exactly
|
|
94
|
+
* a dangling symlink planted at the destination; `copyFileSync`/
|
|
95
|
+
* `symlinkSync` (via `copyPreservingSymlink`) then follow that link and
|
|
96
|
+
* create the attacker-chosen target outside `configDir`. `lstatSync` never
|
|
97
|
+
* follows a symlink and succeeds for a dangling one, so it correctly reports
|
|
98
|
+
* "something is here" (a symlink, whatever its target) rather than "nothing
|
|
99
|
+
* is here". Every path name staged, restored, or recovered is REQUIRED to be
|
|
100
|
+
* a flat name — no path separator of either platform's flavor (`/` or `\`),
|
|
101
|
+
* matching every real caller's actual usage (a single flat filename like
|
|
102
|
+
* `'dev-preferences.md'`) and the `E3/E5` traversal/NUL-byte rejection this
|
|
103
|
+
* module already performs; a name containing a separator is rejected the
|
|
104
|
+
* same way (test-matrix rewritten E-series).
|
|
105
|
+
*
|
|
106
|
+
* Symlink safety: staged files are copied via installer-migrations.cts's
|
|
107
|
+
* `copyPreservingSymlink` (routed through `installFs()` by this same phase),
|
|
108
|
+
* which never dereferences a symlink — a managed path replaced by a link to
|
|
109
|
+
* (e.g.) `~/.ssh/id_rsa` cannot have the referent's bytes copied into the
|
|
110
|
+
* staging tree, or back out of it on restore/recovery (test-matrix A4). This
|
|
111
|
+
* is the SOURCE side; consumers that read a staged copy's CONTENT back
|
|
112
|
+
* (rather than re-copying it byte-for-byte via `copyPreservingSymlink`) must
|
|
113
|
+
* separately check `lstatSync(...).isSymbolicLink()` before calling
|
|
114
|
+
* `readFileSync` on it, or `readFileSync` will happily follow the staged
|
|
115
|
+
* symlink and read the referent's bytes — see install-engine.cts's
|
|
116
|
+
* `_runLegacyInstallMigrations` call site for the guarded pattern.
|
|
117
|
+
*
|
|
118
|
+
* Failure posture: staging (`stageUserArtifacts`) throws on any real IO
|
|
119
|
+
* failure — an existing file that cannot be copied, or the staging directory
|
|
120
|
+
* itself cannot be created. This is deliberate (test-matrix D4, a
|
|
121
|
+
* correctness row, not an error-handling row): a caller MUST let this
|
|
122
|
+
* propagate and abort BEFORE wiping the source directory, or the wipe
|
|
123
|
+
* proceeds having staged nothing, which is worse than no staging at all.
|
|
124
|
+
* `restoreStagedUserArtifacts`/`discardStagedUserArtifacts`/
|
|
125
|
+
* `recoverOrphanedUserArtifacts` degrade instead: a missing staged file, a
|
|
126
|
+
* missing `stagingRoot`, or a malformed record are all treated as "nothing
|
|
127
|
+
* to do", never a throw — the durability property this module exists for
|
|
128
|
+
* would be self-defeating if RECOVERY could itself crash an install.
|
|
129
|
+
* `recoverOrphanedUserArtifacts`'s "never throws" contract is enforced with
|
|
130
|
+
* a per-FILE try/catch around every copy (a failure recovering one name is
|
|
131
|
+
* reported and skipped, the rest of the batch still proceeds) wrapped in a
|
|
132
|
+
* per-ENTRY try/catch around the whole batch (a failure this module did not
|
|
133
|
+
* anticipate — e.g. `symlinkSync` throwing `EPERM` for an unprivileged
|
|
134
|
+
* Windows user, or a staged `files/<name>` that is unexpectedly a directory
|
|
135
|
+
* — is reported and the loop moves to the NEXT staging entry rather than
|
|
136
|
+
* propagating out of the function entirely). A batch that cannot be
|
|
137
|
+
* recovered is never swept — it is left in place for a future run or manual
|
|
138
|
+
* inspection, matching this module's existing "malformed record left alone"
|
|
139
|
+
* precedent (C6).
|
|
140
|
+
*
|
|
141
|
+
* CONCURRENCY (#2875 defect fix, test-matrix row F1 — previously documented
|
|
142
|
+
* as an open limitation requiring a cross-process lock; that reasoning was
|
|
143
|
+
* revisited and found unnecessarily strong): two RUNS (processes) racing the
|
|
144
|
+
* same `destDir` no longer share an entry directory. The staging key is
|
|
145
|
+
* `sha256(destDir)` COMBINED with `sha256(runId)` (`opts.runId`, default
|
|
146
|
+
* `String(process.pid)`) — the OS guarantees PID uniqueness among
|
|
147
|
+
* SIMULTANEOUSLY RUNNING processes, so two concurrently-live installs always
|
|
148
|
+
* key to different entry directories, and `stageUserArtifacts`'s
|
|
149
|
+
* entryDir-clearing step / `recoverOrphanedUserArtifacts`'s end-of-batch
|
|
150
|
+
* `rmSync` can therefore never destroy a DIFFERENT live run's in-flight or
|
|
151
|
+
* just-committed batch — the destructive clear is safe by construction, no
|
|
152
|
+
* lock needed. A crashed run's orphaned entry is not lost either: it is
|
|
153
|
+
* swept the ordinary way, by a LATER run's `recoverOrphanedUserArtifacts`
|
|
154
|
+
* pass (module doc "Failure posture" / C1-C4) — recovery iterates every
|
|
155
|
+
* entry under `stagingRoot` regardless of which run's key produced it, and
|
|
156
|
+
* the C2 never-overwrite guard means a stale orphan from an earlier crashed
|
|
157
|
+
* run can never clobber a newer, already-restored file.
|
|
158
|
+
*
|
|
159
|
+
* OWNER-LIVENESS GUARD (#2875 defect fix, closes the F1 residual above — a
|
|
160
|
+
* SECOND run's recovery pass executing WHILE a first, still-live run is
|
|
161
|
+
* between `stageUserArtifacts`'s commit and its own
|
|
162
|
+
* `restoreStagedUserArtifacts`/`discardStagedUserArtifacts` call is not an
|
|
163
|
+
* exotic interleaving: `recoverOrphanedUserArtifacts` runs as the FIRST
|
|
164
|
+
* statement of both `install()` and `uninstall()`, so it is exactly what
|
|
165
|
+
* happens whenever a second install starts while a first is still inside its
|
|
166
|
+
* wipe): `record.json` now carries `runId` RAW (unhashed — `stagingKeyFor`
|
|
167
|
+
* still only ever sees the hash) alongside `timestamp`, and
|
|
168
|
+
* `recoverOrphanedUserArtifacts` treats an entry as belonging to a STILL-LIVE
|
|
169
|
+
* run — leaving it COMPLETELY untouched, neither recovered nor swept, never
|
|
170
|
+
* even attempting `assertDestWithinConfigHome` against it — when ALL of:
|
|
171
|
+
* (1) `runId` parses as a valid positive-integer pid (`parseOwnerPid` —
|
|
172
|
+
* `"0"` and negative values are excluded because POSIX treats those as a
|
|
173
|
+
* process-GROUP signal, never a single pid); (2) `process.kill(pid, 0)`
|
|
174
|
+
* does not report `ESRCH` (`isProcessAlive` — cross-platform pid-existence
|
|
175
|
+
* probe that sends no actual signal; any outcome OTHER than "provably dead"
|
|
176
|
+
* is treated as "alive", including `EPERM`); (3) the record's `timestamp` is
|
|
177
|
+
* within `OWNER_LIVENESS_GRACE_MS` (5 minutes — a generous multiple of this
|
|
178
|
+
* codebase's own 60s npm-subprocess timeout convention, chosen because the
|
|
179
|
+
* failure direction is asymmetric: skipping a genuine orphan for up to 5
|
|
180
|
+
* minutes merely delays recovery, the bytes stay on disk, whereas sweeping a
|
|
181
|
+
* live run's entry destroys data outright) of `clock.now()` — the pid-reuse
|
|
182
|
+
* guard: an OS pid recycled onto an unrelated, currently-alive later process
|
|
183
|
+
* cannot mask a genuine orphan past this window regardless of what
|
|
184
|
+
* `process.kill` reports. `recoverOrphanedUserArtifacts` now accepts the
|
|
185
|
+
* SAME `{clock}` seam `stageUserArtifacts` already does (default the real
|
|
186
|
+
* `Date`) — never reads the wall clock directly. Every one of these checks
|
|
187
|
+
* degrades toward NOT protecting (i.e. toward the pre-this-fix, always-
|
|
188
|
+
* eligible-for-recovery behavior) rather than toward protecting forever: a
|
|
189
|
+
* missing/non-numeric `runId` (an old record, or a hand-edited one) is never
|
|
190
|
+
* treated as live, and an unparsable `timestamp` never grants indefinite
|
|
191
|
+
* protection — see `parseOwnerPid`/`ownerStillLive`'s own doc comments.
|
|
192
|
+
*
|
|
193
|
+
* This closes the F1 residual as previously documented: recovery no longer
|
|
194
|
+
* has any interleaving with a live peer run that can destroy that peer's
|
|
195
|
+
* data. What remains, and is inherent to any liveness probe rather than a
|
|
196
|
+
* gap in this specific check: a false "alive" reading (an unrelated process
|
|
197
|
+
* reusing the crashed run's exact pid, itself started within the same
|
|
198
|
+
* `OWNER_LIVENESS_GRACE_MS` window) delays that one orphan's recovery by up
|
|
199
|
+
* to 5 minutes — never data loss, only a bounded delay, and exactly the
|
|
200
|
+
* direction this guard is biased toward.
|
|
201
|
+
*
|
|
202
|
+
* Explicitly out of scope (40-design.md "Explicitly out of scope"): fsync
|
|
203
|
+
* durability (crash-safe against process death only, not power loss);
|
|
204
|
+
* routing the raw-`fs` uninstall wipe at call site 2
|
|
205
|
+
* (`_runLegacyUninstallCleanup`) — only the staging call itself routes
|
|
206
|
+
* through `installFs()` there, the surrounding wipe stays raw `fs` by
|
|
207
|
+
* design (Phase 5 deliberately left the uninstall tree unrouted).
|
|
208
|
+
*/
|
|
209
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
210
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
211
|
+
// #2875: this module's own fs seam — see install-fs-adapter.cts's module doc
|
|
212
|
+
// for the ambient-adapter delivery mechanism this reuses unchanged.
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
214
|
+
const installFsAdapter = require("./install-fs-adapter.cjs");
|
|
215
|
+
const { installFs } = installFsAdapter;
|
|
216
|
+
// assertDestWithinConfigHome: the confinement decision this module reuses
|
|
217
|
+
// rather than reimplements (see module doc). Accessed via module ref at call
|
|
218
|
+
// time, matching install-engine.cts's own documented pattern for the same
|
|
219
|
+
// function, for test-stub compatibility.
|
|
220
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
221
|
+
const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
|
|
222
|
+
// copyPreservingSymlink: the symlink-safe copy primitive this module reuses
|
|
223
|
+
// rather than reimplements (see module doc "Symlink safety").
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
225
|
+
const installerMigrations = require("./installer-migrations.cjs");
|
|
226
|
+
function stagingKeyFor(destDir, runId) {
|
|
227
|
+
const destHash = node_crypto_1.default.createHash('sha256').update(node_path_1.default.resolve(destDir)).digest('hex').slice(0, 16);
|
|
228
|
+
// #2875 defect fix (test-matrix F1): hashed, not used raw — bounds the
|
|
229
|
+
// discriminator's contribution to the entry-dir name and keeps it
|
|
230
|
+
// filesystem-safe regardless of what a caller passes as `runId`, matching
|
|
231
|
+
// the same treatment `destDir` already gets.
|
|
232
|
+
const runHash = node_crypto_1.default.createHash('sha256').update(runId).digest('hex').slice(0, 8);
|
|
233
|
+
return `${destHash}-${runHash}`;
|
|
234
|
+
}
|
|
235
|
+
// #2875 defect fix (test-matrix F1 residual — owner-liveness guard): the
|
|
236
|
+
// grace window past which a record is treated as orphaned REGARDLESS of
|
|
237
|
+
// whether `process.kill(pid, 0)` still reports the pid as alive — closes the
|
|
238
|
+
// pid-reuse gap (a crashed run's pid reassigned to an unrelated later
|
|
239
|
+
// process must not mask a genuine orphan forever). 5 minutes is a generous
|
|
240
|
+
// multiple of the codebase's own bound on a single install-tree operation
|
|
241
|
+
// (`npm` subprocess timeout convention is 60s — see this module's own
|
|
242
|
+
// "KNOWN DEFECTS" precedent in CLAUDE.md's "Unbounded Subprocesses" row);
|
|
243
|
+
// staging's own copy loop is a handful of small, flat, user-owned files, far
|
|
244
|
+
// cheaper than an `npm` call. The FAILURE DIRECTION is asymmetric by design
|
|
245
|
+
// (module doc "Owner-liveness guard"): skipping a real orphan for up to this
|
|
246
|
+
// long merely delays recovery — the bytes stay on disk — whereas sweeping a
|
|
247
|
+
// live run's entry destroys data outright, so this constant is deliberately
|
|
248
|
+
// generous rather than tight.
|
|
249
|
+
const OWNER_LIVENESS_GRACE_MS = 5 * 60 * 1000;
|
|
250
|
+
/**
|
|
251
|
+
* Parses `runId` (raw, from an on-disk `record.json` — attacker/hand-edit
|
|
252
|
+
* influenceable, same threat model as every other on-disk field this module
|
|
253
|
+
* reads, module doc "Confinement") into a pid `process.kill` can safely take.
|
|
254
|
+
* Returns `null` — never throws — for anything that is not a plain positive
|
|
255
|
+
* integer string: absent (pre-this-fix record), the wrong type (a
|
|
256
|
+
* hand-edited record could put a number, an object, anything), `"0"`
|
|
257
|
+
* (`process.kill(0, ...)` signals the WHOLE process group, never a single
|
|
258
|
+
* pid — deliberately excluded by the `[1-9]` leading-digit requirement), or
|
|
259
|
+
* a negative/non-integer value (POSIX also treats a negative pid as a
|
|
260
|
+
* process-GROUP signal). `null` here means "no liveness claim to evaluate"
|
|
261
|
+
* — the caller falls back to unconditional recovery eligibility, the exact
|
|
262
|
+
* pre-this-fix behavior, so an old or malformed record is never treated as
|
|
263
|
+
* "live forever" (module doc "Owner-liveness guard").
|
|
264
|
+
*/
|
|
265
|
+
function parseOwnerPid(runId) {
|
|
266
|
+
if (typeof runId !== 'string' || !/^[1-9][0-9]*$/.test(runId))
|
|
267
|
+
return null;
|
|
268
|
+
const pid = Number(runId);
|
|
269
|
+
return Number.isSafeInteger(pid) ? pid : null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* True when a process with this pid currently exists. `process.kill(pid, 0)`
|
|
273
|
+
* sends no signal, only probes existence — throws `ESRCH` ("no such
|
|
274
|
+
* process") when it is provably dead. Any OTHER outcome (`EPERM` — the
|
|
275
|
+
* process exists but this process lacks permission to signal it; any other
|
|
276
|
+
* platform quirk) is treated as "still alive" — the same NOT-sweeping bias
|
|
277
|
+
* `parseOwnerPid`/`OWNER_LIVENESS_GRACE_MS` apply: an ambiguous liveness
|
|
278
|
+
* result must never be read as license to sweep.
|
|
279
|
+
*/
|
|
280
|
+
function isProcessAlive(pid) {
|
|
281
|
+
try {
|
|
282
|
+
process.kill(pid, 0);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return err.code !== 'ESRCH';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* True when `record` still belongs to a run recovery must leave entirely
|
|
291
|
+
* alone (module doc "Owner-liveness guard") — a valid pid (`parseOwnerPid`),
|
|
292
|
+
* that pid currently exists (`isProcessAlive`), AND the record is within
|
|
293
|
+
* `OWNER_LIVENESS_GRACE_MS` of `clock.now()`. All three degrade toward
|
|
294
|
+
* "not protected" (eligible for ordinary recovery, this function's existing
|
|
295
|
+
* pre-this-fix behavior) rather than toward "protected forever": a missing
|
|
296
|
+
* pid, a dead pid, or an unparsable/missing `timestamp` (already required
|
|
297
|
+
* and validated elsewhere in this module, but re-checked here defensively)
|
|
298
|
+
* all return `false`. The grace check is unconditional once a pid IS deemed
|
|
299
|
+
* alive — a stale-but-apparently-live claim past the grace window is treated
|
|
300
|
+
* as orphaned regardless (the pid-reuse guard).
|
|
301
|
+
*/
|
|
302
|
+
function ownerStillLive(record, clock) {
|
|
303
|
+
const pid = parseOwnerPid(record.runId);
|
|
304
|
+
if (pid === null)
|
|
305
|
+
return false;
|
|
306
|
+
if (!isProcessAlive(pid))
|
|
307
|
+
return false;
|
|
308
|
+
const recordMs = Date.parse(record.timestamp);
|
|
309
|
+
if (!Number.isFinite(recordMs))
|
|
310
|
+
return false;
|
|
311
|
+
return clock.now() - recordMs < OWNER_LIVENESS_GRACE_MS;
|
|
312
|
+
}
|
|
313
|
+
function _installEngineSymlinkGuard() {
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
315
|
+
const mod = require('./install-engine.cjs');
|
|
316
|
+
return mod;
|
|
317
|
+
}
|
|
318
|
+
/** Flat names only — no path separator of either platform's flavor. See
|
|
319
|
+
* module doc "Never-overwrite (C2) and destination-symlink refusal". */
|
|
320
|
+
function isFlatName(name) {
|
|
321
|
+
return !name.includes('/') && !name.includes('\\');
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* True when `p` has ANYTHING at all on disk — including a dangling symlink,
|
|
325
|
+
* which `existsSync` reports as absent (see module doc). Used everywhere
|
|
326
|
+
* this module must refuse to write over an existing destination rather than
|
|
327
|
+
* trusting `existsSync`.
|
|
328
|
+
*/
|
|
329
|
+
function lstatPresent(p) {
|
|
330
|
+
try {
|
|
331
|
+
installFs().lstatSync(p);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Copy `srcPath` to `destPath` without ever dereferencing a symlink — thin
|
|
340
|
+
* wrapper over installer-migrations.cts's `copyPreservingSymlink`, routed
|
|
341
|
+
* through the SAME `installFs()` adapter active for this call (ambient; see
|
|
342
|
+
* install-fs-adapter.cts's module doc).
|
|
343
|
+
*/
|
|
344
|
+
function stagedCopy(srcPath, destPath) {
|
|
345
|
+
installerMigrations.copyPreservingSymlink(srcPath, destPath);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Stage `fileNames` found in `destDir` to durable on-disk storage under
|
|
349
|
+
* `stagingRoot`, BEFORE the caller wipes `destDir`.
|
|
350
|
+
*
|
|
351
|
+
* Absent files are skipped, never an error (A2). Throws on any real IO
|
|
352
|
+
* failure — see module doc "Failure posture" (D4): callers MUST let this
|
|
353
|
+
* propagate and abort before wiping `destDir`.
|
|
354
|
+
*
|
|
355
|
+
* `stagingRoot` itself is NOT re-confined here — callers own that (module
|
|
356
|
+
* doc "Confinement").
|
|
357
|
+
*
|
|
358
|
+
* The staging entry dir is cleared FIRST (removed, then recreated) so a
|
|
359
|
+
* repeat call for the same `destDir` FROM THE SAME RUN (same `runId`, the
|
|
360
|
+
* default `process.pid` case — A6) never leaves files from a PRIOR batch
|
|
361
|
+
* lingering alongside the new one — recovery and restore both iterate
|
|
362
|
+
* `record.names` so stale leftovers were already inert, but a stale-free
|
|
363
|
+
* staging tree is what an operator inspecting it on disk expects to see. A
|
|
364
|
+
* DIFFERENT run's entry for the same `destDir` keys differently (module doc
|
|
365
|
+
* "Concurrency") and is never touched by this clearing step.
|
|
366
|
+
*/
|
|
367
|
+
function stageUserArtifacts(destDir, fileNames, stagingRoot, opts = {}) {
|
|
368
|
+
const { clock = Date, runId = String(process.pid) } = opts;
|
|
369
|
+
const key = stagingKeyFor(destDir, runId);
|
|
370
|
+
const entryDir = node_path_1.default.join(stagingRoot, key);
|
|
371
|
+
const filesDir = node_path_1.default.join(entryDir, 'files');
|
|
372
|
+
const recordPath = node_path_1.default.join(entryDir, 'record.json');
|
|
373
|
+
installFs().rmSync(entryDir, { recursive: true, force: true });
|
|
374
|
+
installFs().mkdirSync(filesDir, { recursive: true });
|
|
375
|
+
const stagedNames = [];
|
|
376
|
+
for (const name of fileNames) {
|
|
377
|
+
// #2875 defect fix: flat names only (module doc "Confinement") — a
|
|
378
|
+
// caller passing a name with a path separator is a caller bug, hard-
|
|
379
|
+
// throw matching this function's own "Failure posture" (D4), never a
|
|
380
|
+
// silent skip.
|
|
381
|
+
if (!isFlatName(name)) {
|
|
382
|
+
throw new Error(`stageUserArtifacts: file name "${name}" must be a flat name — no path separator is allowed`);
|
|
383
|
+
}
|
|
384
|
+
const srcPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(destDir, name);
|
|
385
|
+
if (!installFs().existsSync(srcPath))
|
|
386
|
+
continue; // A2: absent, not staged, no throw
|
|
387
|
+
const destInStaging = runtimeArtifactInstallPlan.assertDestWithinConfigHome(filesDir, name);
|
|
388
|
+
stagedCopy(srcPath, destInStaging);
|
|
389
|
+
stagedNames.push(name);
|
|
390
|
+
}
|
|
391
|
+
// Commit point (A1/A7): written AFTER every copy lands. A crash before
|
|
392
|
+
// this line leaves `filesDir` populated but recordless —
|
|
393
|
+
// recoverOrphanedUserArtifacts treats that as incomplete, never a recovery
|
|
394
|
+
// source (B4).
|
|
395
|
+
const record = {
|
|
396
|
+
destDir: node_path_1.default.resolve(destDir),
|
|
397
|
+
names: stagedNames,
|
|
398
|
+
timestamp: new Date(clock.now()).toISOString(),
|
|
399
|
+
// #2875 defect fix (F1 residual — owner-liveness guard): carried RAW so
|
|
400
|
+
// a later recovery pass can tell a still-live owner from a genuine
|
|
401
|
+
// orphan (module doc "Owner-liveness guard") — see `ownerStillLive`.
|
|
402
|
+
runId,
|
|
403
|
+
};
|
|
404
|
+
installFs().writeFileSync(recordPath, JSON.stringify(record), 'utf8');
|
|
405
|
+
return { destDir: record.destDir, stagingRoot, entryDir, filesDir, recordPath, names: stagedNames };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Copy every staged file back into `destDir` (normally the SAME destDir the
|
|
409
|
+
* batch was staged from, after the caller recreated it post-wipe).
|
|
410
|
+
*
|
|
411
|
+
* Deliberately does NOT discard the staged copy — kept a separate step
|
|
412
|
+
* (`discardStagedUserArtifacts`) because not every call site restores
|
|
413
|
+
* unconditionally (e.g. a migration call site restores only on migration
|
|
414
|
+
* FAILURE, and wants the staged copy gone only once that decision is final).
|
|
415
|
+
*
|
|
416
|
+
* `opts.rename` restores a staged name under a DIFFERENT destination file
|
|
417
|
+
* name (e.g. a legacy `dev-preferences.md` restored as
|
|
418
|
+
* `gsd-dev-preferences.md`) — see `RestoreOptions`'s own doc comment. A name
|
|
419
|
+
* absent from the map restores under its own staged name, unchanged.
|
|
420
|
+
*
|
|
421
|
+
* A name skips (never restores) if it is not a flat name (module doc
|
|
422
|
+
* "Confinement"), or if `destPath` already has ANYTHING at it — including a
|
|
423
|
+
* dangling symlink, which `existsSync` cannot see (module doc
|
|
424
|
+
* "Never-overwrite (C2) and destination-symlink refusal", #2875 defect fix):
|
|
425
|
+
* this function has no "already present, that's fine" semantics to fall
|
|
426
|
+
* back on the way `recoverOrphanedUserArtifacts` does, so the safe,
|
|
427
|
+
* degrade-not-throw choice is to refuse writing through whatever is already
|
|
428
|
+
* there rather than silently following it.
|
|
429
|
+
*/
|
|
430
|
+
function restoreStagedUserArtifacts(destDir, staged, opts = {}) {
|
|
431
|
+
const { rename = {} } = opts;
|
|
432
|
+
for (const name of staged.names) {
|
|
433
|
+
if (!isFlatName(name))
|
|
434
|
+
continue;
|
|
435
|
+
const srcInStaging = runtimeArtifactInstallPlan.assertDestWithinConfigHome(staged.filesDir, name);
|
|
436
|
+
if (!installFs().existsSync(srcInStaging))
|
|
437
|
+
continue;
|
|
438
|
+
const destName = Object.prototype.hasOwnProperty.call(rename, name) ? rename[name] : name;
|
|
439
|
+
if (!isFlatName(destName))
|
|
440
|
+
continue;
|
|
441
|
+
const destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(destDir, destName);
|
|
442
|
+
// #2875 defect fix: refuse to write through a pre-existing symlink at
|
|
443
|
+
// destPath (dangling or not) — copyFileSync/symlinkSync would otherwise
|
|
444
|
+
// follow it and land content outside destDir.
|
|
445
|
+
if (lstatPresent(destPath))
|
|
446
|
+
continue;
|
|
447
|
+
installFs().mkdirSync(node_path_1.default.dirname(destPath), { recursive: true });
|
|
448
|
+
stagedCopy(srcInStaging, destPath);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Remove a staging batch entirely. A staged copy is not a backup — see
|
|
453
|
+
* module doc "Explicitly out of scope" / 40-design.md "Not-corruption".
|
|
454
|
+
* Idempotent: a missing `entryDir` is a silent no-op (`force: true`),
|
|
455
|
+
* matching every sibling best-effort cleanup in this codebase's install
|
|
456
|
+
* tree.
|
|
457
|
+
*/
|
|
458
|
+
function discardStagedUserArtifacts(staged) {
|
|
459
|
+
installFs().rmSync(staged.entryDir, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Find and restore every COMPLETE (record-committed) staging batch under
|
|
463
|
+
* `stagingRoot` — batches orphaned by a crash between `stageUserArtifacts`
|
|
464
|
+
* and the caller's own restore/discard.
|
|
465
|
+
*
|
|
466
|
+
* Never overwrites a present file (C2 — a file that IS there was not lost;
|
|
467
|
+
* "present" is decided by `lstatSync`, not `existsSync`, so a dangling
|
|
468
|
+
* symlink at the destination also counts as present — module doc
|
|
469
|
+
* "Never-overwrite (C2) and destination-symlink refusal", #2875 defect fix).
|
|
470
|
+
* Never throws, PERIOD: a missing `stagingRoot` (C5), a malformed/truncated
|
|
471
|
+
* record (C6), a record naming a `destDir` outside `configDir` either
|
|
472
|
+
* lexically or through a symlinked ancestor (E2), or ANY unanticipated
|
|
473
|
+
* failure recovering one file or one whole batch (a `symlinkSync` `EPERM`
|
|
474
|
+
* for an unprivileged Windows user, a staged `files/<name>` that turns out
|
|
475
|
+
* to be a directory, an `mkdirSync`/`rmSync` failure) is reported via
|
|
476
|
+
* `skipped` and the function moves on — to the next name, then to the next
|
|
477
|
+
* staging entry — rather than propagating (#2875 defect fix: this contract
|
|
478
|
+
* was previously false; `mkdirSync`/`stagedCopy`/the final `rmSync` were all
|
|
479
|
+
* unguarded, so a single bad entry could throw out of this function
|
|
480
|
+
* entirely, and because the throw happened before that entry was ever
|
|
481
|
+
* cleaned up, it also permanently bricked every FUTURE install/uninstall —
|
|
482
|
+
* this function is called as the very first step of both).
|
|
483
|
+
*
|
|
484
|
+
* Re-confinement (E2): the record's `destDir` is data, not policy — this
|
|
485
|
+
* function never trusts it directly. `configDir` is a REQUIRED, EXPLICIT
|
|
486
|
+
* parameter (not derived from `stagingRoot`'s own path shape — resting E2's
|
|
487
|
+
* confinement guarantee on a path-naming convention would let a caller that
|
|
488
|
+
* passes a differently-shaped `stagingRoot` silently get the wrong
|
|
489
|
+
* confinement root, in either direction, which is exactly what E2 exists to
|
|
490
|
+
* prevent). The recorded `destDir` is re-resolved through the SAME
|
|
491
|
+
* `assertDestWithinConfigHome` every other write on this call tree uses
|
|
492
|
+
* against the CALLER-SUPPLIED `configDir`, THEN through the SAME
|
|
493
|
+
* `hasExistingSymlinkBetween` `_copyStaged`/`migrateLegacyDevPreferencesToSkill`
|
|
494
|
+
* apply to their own writes (#2875 defect fix — `assertDestWithinConfigHome`
|
|
495
|
+
* alone is pure lexical `path.resolve` string math and cannot see a
|
|
496
|
+
* symlinked ANCESTOR directory between `configDir` and the recorded
|
|
497
|
+
* `destDir`). A record naming a `destDir` outside it, lexically or via a
|
|
498
|
+
* symlinked ancestor, is refused (`skipped`), never written.
|
|
499
|
+
*
|
|
500
|
+
* Callers MUST invoke this before the ordinary preserve step, from a
|
|
501
|
+
* PRODUCTION entry point (test-matrix C7 — anti-inertness). Calling this
|
|
502
|
+
* function directly and never wiring it into a real install path is exactly
|
|
503
|
+
* the #1879-F15 inert-fix failure mode this module exists to avoid; see
|
|
504
|
+
* bin/install.js's `install()`/`uninstall()` for the wiring.
|
|
505
|
+
*/
|
|
506
|
+
function recoverOrphanedUserArtifacts(stagingRoot, configDir, opts = {}) {
|
|
507
|
+
const { clock = Date } = opts;
|
|
508
|
+
const result = { recovered: [], skipped: [] };
|
|
509
|
+
if (!installFs().existsSync(stagingRoot))
|
|
510
|
+
return result; // C5: no-op, no throw, no dir created
|
|
511
|
+
let entries;
|
|
512
|
+
try {
|
|
513
|
+
entries = installFs().readdirSync(stagingRoot, { withFileTypes: true });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
const configHome = node_path_1.default.resolve(configDir);
|
|
519
|
+
const symlinkGuard = _installEngineSymlinkGuard();
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
if (!entry.isDirectory())
|
|
522
|
+
continue;
|
|
523
|
+
const entryDir = node_path_1.default.join(stagingRoot, entry.name);
|
|
524
|
+
// #2875 defect fix: the whole per-entry body is wrapped so nothing this
|
|
525
|
+
// module did not anticipate can propagate out of the function — see the
|
|
526
|
+
// doc comment above. A caught failure is reported once for the batch and
|
|
527
|
+
// the loop proceeds to the NEXT entry; it is deliberately NOT swept
|
|
528
|
+
// (matches the existing "malformed record left alone" precedent, C6) so
|
|
529
|
+
// it remains available for inspection or a future recovery attempt.
|
|
530
|
+
try {
|
|
531
|
+
const recordPath = node_path_1.default.join(entryDir, 'record.json');
|
|
532
|
+
if (!installFs().existsSync(recordPath))
|
|
533
|
+
continue; // B4: half-staged, not a recovery source
|
|
534
|
+
let record;
|
|
535
|
+
try {
|
|
536
|
+
const parsed = JSON.parse(installFs().readFileSync(recordPath, 'utf8'));
|
|
537
|
+
if (!parsed || typeof parsed !== 'object' ||
|
|
538
|
+
typeof parsed.destDir !== 'string' ||
|
|
539
|
+
!Array.isArray(parsed.names) ||
|
|
540
|
+
!parsed.names.every((n) => typeof n === 'string')) {
|
|
541
|
+
continue; // C6: malformed shape, ignored — never a crash
|
|
542
|
+
}
|
|
543
|
+
record = parsed;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
continue; // C6: corrupt/truncated JSON, ignored — never a crash
|
|
547
|
+
}
|
|
548
|
+
// #2875 defect fix (F1 residual — owner-liveness guard): checked
|
|
549
|
+
// BEFORE any confinement resolution or write attempt — a still-live
|
|
550
|
+
// owner's entry is left completely untouched, not merely un-swept
|
|
551
|
+
// (module doc "Owner-liveness guard"): this run does not restore the
|
|
552
|
+
// file to destDir on the live owner's behalf either, which would race
|
|
553
|
+
// that owner's own still-in-progress wipe/restore cycle.
|
|
554
|
+
if (ownerStillLive(record, clock)) {
|
|
555
|
+
result.skipped.push({ entryDir, reason: 'owner-still-live' });
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const filesDir = node_path_1.default.join(entryDir, 'files');
|
|
559
|
+
// #2875 defect fix (security — source-side symlink escape): every write
|
|
560
|
+
// below reads FROM filesDir via a plain srcPath = filesDir/name join
|
|
561
|
+
// (E3/E5 guard it against traversal/NUL, never against the `files`
|
|
562
|
+
// PATH COMPONENT ITSELF being a symlink). record.json is data an
|
|
563
|
+
// entryDir owner controls (module doc "Confinement" — same
|
|
564
|
+
// attacker-influenceable-on-a-shared-machine threat E2 already treats
|
|
565
|
+
// destDir against); a real `entryDir` whose `files` child is a symlink
|
|
566
|
+
// to e.g. `/etc` or `/root/.ssh` would have its referent's bytes
|
|
567
|
+
// dereferenced by the per-file `existsSync`/`stagedCopy` reads below,
|
|
568
|
+
// landing victim-readable content at an attacker-named path inside
|
|
569
|
+
// configDir. Reuse the SAME `hasExistingSymlinkBetween` guard the
|
|
570
|
+
// dest side (E2) and every other write on this call tree already
|
|
571
|
+
// applies, walked from entryDir (a real, non-symlinked directory —
|
|
572
|
+
// `entry.isDirectory()` above already excludes a symlinked entryDir on
|
|
573
|
+
// POSIX) to filesDir, BEFORE any name in `record.names` is read.
|
|
574
|
+
// Per-file symlinks INSIDE files/ are untouched by this check and
|
|
575
|
+
// remain legitimate (module doc "Symlink safety" — a symlinked staged
|
|
576
|
+
// user artifact is expected and copied via copyPreservingSymlink,
|
|
577
|
+
// never dereferenced).
|
|
578
|
+
//
|
|
579
|
+
// `allowOptInFollow` is hardcoded `false` here, NOT
|
|
580
|
+
// `symlinkGuard.isSymlinkedDestOptIn()` — `GSD_ALLOW_SYMLINKED_DEST` is
|
|
581
|
+
// documented (install-engine.cts `isSymlinkedDestOptIn`) as relaxing
|
|
582
|
+
// only the destination-side "pre-existing symlink that points outside
|
|
583
|
+
// configHome" refusal (the user asserting they own/trust a symlinked
|
|
584
|
+
// WRITE destination, e.g. nix-darwin's `~/.claude` symlink). This is a
|
|
585
|
+
// SOURCE read path — `entryDir`/`filesDir` is GSD-owned internal
|
|
586
|
+
// staging state this module itself creates, never a user-authored
|
|
587
|
+
// layout, so there is no legitimate opt-in case here. Honoring the
|
|
588
|
+
// dest opt-in on this read would re-open exactly the escape this
|
|
589
|
+
// guard exists to close: a `files` component symlinked to e.g.
|
|
590
|
+
// `/root/.ssh` would be followed, and the per-file reads below would
|
|
591
|
+
// dereference and copy the referent's bytes into configDir.
|
|
592
|
+
if (symlinkGuard.hasExistingSymlinkBetween(entryDir, filesDir, { allowOptInFollow: false })) {
|
|
593
|
+
result.skipped.push({ entryDir, reason: 'files-symlink-escape' });
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// #2875 defect fix (security — cwd-dependent confinement): a relative
|
|
597
|
+
// `record.destDir` makes `path.relative(configHome, record.destDir)`
|
|
598
|
+
// resolve the SECOND (relative) argument against `process.cwd()`
|
|
599
|
+
// internally, not against `configHome` — the CLI's cwd at the moment
|
|
600
|
+
// recovery runs, which an attacker who can plant a record.json does
|
|
601
|
+
// not need to know or control to exploit (module doc "Confinement").
|
|
602
|
+
// `stageUserArtifacts` (this module's own writer) always records an
|
|
603
|
+
// ALREADY-`path.resolve`d, absolute `destDir` — a relative value here
|
|
604
|
+
// only ever comes from a forged or hand-edited record, exactly the
|
|
605
|
+
// untrusted-data case this function's confinement re-resolution
|
|
606
|
+
// exists for. Refuse it the same way a lexically-escaping absolute
|
|
607
|
+
// destDir is refused below, rather than let `path.relative` silently
|
|
608
|
+
// reinterpret it against the wrong root.
|
|
609
|
+
if (!node_path_1.default.isAbsolute(record.destDir)) {
|
|
610
|
+
result.skipped.push({ entryDir, reason: 'destDir-outside-confinement' });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
let confinedDestDir;
|
|
614
|
+
try {
|
|
615
|
+
const relDest = node_path_1.default.relative(configHome, record.destDir);
|
|
616
|
+
confinedDestDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configHome, relDest);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
result.skipped.push({ entryDir, reason: 'destDir-outside-confinement' }); // E2 (lexical)
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
// E2 (ancestor symlink): assertDestWithinConfigHome above is pure
|
|
623
|
+
// lexical path math and does not see a symlinked ANCESTOR directory
|
|
624
|
+
// between configHome and confinedDestDir — reuse the SAME guard every
|
|
625
|
+
// other write on this call tree applies (module doc "Confinement").
|
|
626
|
+
if (symlinkGuard.hasExistingSymlinkBetween(configHome, confinedDestDir, { allowOptInFollow: symlinkGuard.isSymlinkedDestOptIn() })) {
|
|
627
|
+
result.skipped.push({ entryDir, reason: 'destDir-symlink-escape' }); // E2
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
// #2875 defect fix: tracks whether ANY name in this batch hit a
|
|
631
|
+
// genuine, unanticipated recovery error (as opposed to a deliberate,
|
|
632
|
+
// accounted-for skip like "already present" or "rejected name") — see
|
|
633
|
+
// the cleanup decision below.
|
|
634
|
+
let anyGenuineFailure = false;
|
|
635
|
+
for (const name of record.names) {
|
|
636
|
+
// Per-FILE guard (#2875 defect fix): one bad name must not abort the
|
|
637
|
+
// rest of the batch.
|
|
638
|
+
try {
|
|
639
|
+
if (!isFlatName(name))
|
|
640
|
+
continue; // flat names only — module doc "Confinement"
|
|
641
|
+
let srcPath;
|
|
642
|
+
let destPath;
|
|
643
|
+
try {
|
|
644
|
+
srcPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(filesDir, name);
|
|
645
|
+
destPath = runtimeArtifactInstallPlan.assertDestWithinConfigHome(confinedDestDir, name);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
continue; // E3/E5: traversal or NUL byte in a staged name — reject that name
|
|
649
|
+
}
|
|
650
|
+
if (!installFs().existsSync(srcPath))
|
|
651
|
+
continue;
|
|
652
|
+
// C2 (#2875 defect fix): lstatSync, not existsSync — existsSync
|
|
653
|
+
// FOLLOWS symlinks and reports false for a DANGLING one, so it is
|
|
654
|
+
// blind to exactly the case an attacker would plant at destPath to
|
|
655
|
+
// bypass "never overwrite" (module doc).
|
|
656
|
+
if (lstatPresent(destPath)) {
|
|
657
|
+
result.skipped.push({ entryDir, reason: 'dest-already-present', name }); // C2: never overwrite
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
installFs().mkdirSync(node_path_1.default.dirname(destPath), { recursive: true }); // C3: recreate a since-removed destDir
|
|
661
|
+
stagedCopy(srcPath, destPath);
|
|
662
|
+
result.recovered.push({ destDir: confinedDestDir, name });
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
anyGenuineFailure = true;
|
|
666
|
+
result.skipped.push({ entryDir, reason: 'recover-error', name }); // never throws — degrade per-file
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// #2875 defect fix: a batch with a genuine per-file failure is NEVER
|
|
670
|
+
// swept — discarding the staging entry here would silently destroy the
|
|
671
|
+
// only durable copy of a file this module could not otherwise recover,
|
|
672
|
+
// exactly the loss this module exists to prevent. It is left in place
|
|
673
|
+
// for a future recovery attempt or manual inspection, same as a
|
|
674
|
+
// malformed record (C6) or a half-staged entry (B4). Only a batch that
|
|
675
|
+
// is FULLY, DELIBERATELY accounted for (every name recovered, or
|
|
676
|
+
// deliberately skipped because the destination already had something)
|
|
677
|
+
// is removed — it is not a backup (module doc "Explicitly out of
|
|
678
|
+
// scope"). Best-effort: a failure performing the removal itself (rare
|
|
679
|
+
// — permissions, a Windows symlink-cleanup edge case) is swallowed
|
|
680
|
+
// rather than propagated; the entry is left for a future run.
|
|
681
|
+
if (anyGenuineFailure)
|
|
682
|
+
continue;
|
|
683
|
+
try {
|
|
684
|
+
installFs().rmSync(entryDir, { recursive: true, force: true });
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
// Intentionally swallowed — see comment above.
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
result.skipped.push({ entryDir, reason: 'entry-recovery-error' }); // never throws — degrade per-entry
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
module.exports = {
|
|
697
|
+
stageUserArtifacts,
|
|
698
|
+
restoreStagedUserArtifacts,
|
|
699
|
+
discardStagedUserArtifacts,
|
|
700
|
+
recoverOrphanedUserArtifacts,
|
|
701
|
+
// #2875 defect fix: exported for a fast-check property test (CLAUDE.md
|
|
702
|
+
// "Property-Based Testing" — parsers must carry at least one) — additive,
|
|
703
|
+
// byte-identical for every existing caller of this module.
|
|
704
|
+
parseOwnerPid,
|
|
705
|
+
};
|