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