diff --git a/.changeset/997-plugin-canonical-path-hook.md b/.changeset/997-plugin-canonical-path-hook.md new file mode 100644 index 000000000..e60c876ee --- /dev/null +++ b/.changeset/997-plugin-canonical-path-hook.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1207 +--- +**Claude Code plugin installs no longer fail with empty `@~/.claude/gsd-core/...` includes** — agents, commands, and templates `@`-include the canonical `~/.claude/gsd-core/` path, but a marketplace plugin install (`claude plugin install`) never creates that directory, so every include resolved to nothing and agents (e.g. the executor) failed. A new `SessionStart` hook (`gsd-ensure-canonical-path.js`) symlinks the canonical path's immutable subdirs (`bin`, `contexts`, `references`, `templates`, `workflows`) to the plugin's bundled tree. It is a no-op in classic `bin/install.js` installs, preserves user-generated files (e.g. `USER-PROFILE.md`), prunes stale links so it self-heals after `claude plugin update`, and uses Windows junctions. (#1207) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b69f4c6cd..d9607c92a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -290,6 +290,7 @@ Runtime hooks that integrate with the host AI agent: | `gsd-statusline.js` | `statusLine` | Displays model, task, directory, and context usage bar | | `gsd-context-monitor.js` | `PostToolUse` / `AfterTool` | Injects agent-facing context warnings at 35%/25% remaining | | `gsd-check-update.js` | `SessionStart` | Foreground trigger for the background update check | +| `gsd-ensure-canonical-path.js` | `SessionStart` | For Claude Code plugin installs, symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve; runs first in `SessionStart`, no-op in classic installs, self-heals after `claude plugin update` (#997) | | `gsd-check-update-worker.js` | (helper) | Background worker spawned by `gsd-check-update.js`; no direct event registration | | `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt injection patterns (advisory) | | `gsd-read-injection-scanner.js` | `PostToolUse` | Scans Read tool output for injected instructions in untrusted content | @@ -735,9 +736,14 @@ Runtime Engine (Claude Code / Gemini CLI) │ Reads: stdin (tool event JSON), /tmp/claude-ctx-{session}.json (bridge) │ Writes: stdout (hookSpecificOutput with additionalContext warning) │ - └── SessionStart event ──► gsd-check-update.js - Reads: VERSION file - Writes: ~/.claude/cache/gsd-update-check.json (spawns background process) + └── SessionStart event + ├──► gsd-ensure-canonical-path.js (runs first) + │ Reads: ${CLAUDE_PLUGIN_ROOT}/gsd-core/ (plugin installs only) + │ Writes: ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} symlinks + │ (no-op in classic installs; preserves user files; self-heals) + └──► gsd-check-update.js + Reads: VERSION file + Writes: ~/.claude/cache/gsd-update-check.json (spawns background process) ``` ### Context Monitor Thresholds diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 98cfba0c8..01266e128 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -392,6 +392,7 @@ "gsd-context-monitor.js", "gsd-cursor-post-tool.js", "gsd-cursor-session-start.js", + "gsd-ensure-canonical-path.js", "gsd-graphify-update.sh", "gsd-phase-boundary.sh", "gsd-prompt-guard.js", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 0861799f9..08d2e2eb0 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -520,6 +520,7 @@ Full listing: `hooks/`. | `gsd-read-injection-scanner.js` | `PostToolUse` | Scans tool Read results for prompt-injection patterns (v1.36+, PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | Hard-blocks Edit/Write/MultiEdit with absolute paths outside the worktree root (PR #579, #260) | | `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) | +| `gsd-ensure-canonical-path.js` | `SessionStart` | Symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve in marketplace plugin installs; no-op in classic installs, self-heals after `claude plugin update` (#997) | | `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | | `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | diff --git a/hooks/gsd-ensure-canonical-path.js b/hooks/gsd-ensure-canonical-path.js new file mode 100644 index 000000000..03092af4f --- /dev/null +++ b/hooks/gsd-ensure-canonical-path.js @@ -0,0 +1,305 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// +// gsd-ensure-canonical-path — SessionStart hook (#997) +// +// PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that +// hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows, +// templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand +// environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them. +// In a classic `bin/install.js` install the canonical path is a real directory +// holding the bundled tree, so the includes resolve. In a Claude Code +// *marketplace plugin* install the plugin manager only unpacks the package +// into the version-pinned plugin cache and never runs `bin/install.js`, so +// `~/.claude/gsd-core/` is never created and every @-include resolves to +// nothing — every agent that depends on one fails (e.g. the executor). +// +// FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT +// set and a bundled `gsd-core/` tree found beneath it), ensure +// `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts, +// references, templates, workflows) are symlinked to the plugin's bundled tree. +// This changes ZERO @-references, is a no-op in classic installs (where each +// subdir is already a real directory), preserves user-generated files +// (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals +// after `claude plugin update` rotates the version dir, and uses Windows +// junctions for symlinks on win32. +// +// SECURITY: the resolved bundled-tree path and every per-subdir link target are +// kept strictly inside the resolved plugin root (realpath-normalised, prefix- +// checked). A real (non-symlink) file or directory already sitting at a managed +// link target is NEVER clobbered. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Immutable, bundled subdirectories that the canonical path must expose. These +// are the directories `@~/.claude/gsd-core//...` includes point into. +// User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are +// NOT in this list and are never created, moved, or deleted by this hook. +const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows']; + +/** + * Resolve the canonical runtime config dir for the active runtime. + * + * Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors + * gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The + * canonical GSD tree always lives at `/gsd-core`. + */ +function resolveConfigDir(homeDir, env) { + const envDir = env.CLAUDE_CONFIG_DIR; + if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) { + return envDir; + } + return path.join(homeDir, '.claude'); +} + +/** + * Locate the bundled `gsd-core/` tree beneath a plugin root. + * + * Claude Code unpacks the package so the bundled tree sits at + * `/gsd-core/`. Returns the absolute, realpath-normalised path to + * that directory, or null if it is absent / not a directory. Resolving with + * realpath collapses symlinks/.. so the subsequent containment check is sound. + */ +function resolveBundledTree(pluginRoot) { + if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) { + return null; + } + let root; + try { + root = fs.realpathSync(pluginRoot); + } catch (_) { + return null; // plugin root does not exist + } + const bundled = path.join(root, 'gsd-core'); + let bundledReal; + try { + // The bundled tree must be a real directory (or a symlink to one) that + // resolves to a path inside the plugin root. realpathSync throws ENOENT/ + // ENOTDIR if /gsd-core is absent, so no separate existence + // check is needed. Reject anything that does not resolve to a directory. + bundledReal = fs.realpathSync(bundled); + if (!fs.statSync(bundledReal).isDirectory()) return null; + } catch (_) { + return null; + } + // SECURITY: the resolved bundled tree must stay inside the resolved plugin + // root. A crafted symlink at /gsd-core pointing outside the root + // is rejected — we never link the canonical path at content we do not own. + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) { + return null; + } + return bundledReal; +} + +/** + * The fs.symlinkSync `type` to use for a directory link on a given platform. + * + * On Windows, unprivileged users cannot create symlinks but CAN create + * junctions; 'junction' requires an absolute target (we always pass one). On + * POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable + * without a Windows host. + */ +function dirLinkType(platform) { + return platform === 'win32' ? 'junction' : 'dir'; +} + +/** + * Create a directory symlink (junction on win32) from linkPath -> target. + * Throws on real failure so the caller records it. + */ +function createDirLink(target, linkPath, platform) { + fs.symlinkSync(target, linkPath, dirLinkType(platform)); +} + +/** + * Does `linkPath` already correctly point at `expectedTarget`? + * Used to make the hook idempotent — a correct link is left untouched. + */ +function linkPointsAt(linkPath, expectedTarget) { + try { + if (!fs.lstatSync(linkPath).isSymbolicLink()) return false; + const resolved = fs.realpathSync(linkPath); + return resolved === fs.realpathSync(expectedTarget); + } catch (_) { + return false; + } +} + +/** + * Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs. + * + * Pure, dependency-injected core so tests drive it with a fake home, fake + * plugin root, and explicit platform. Returns a structured result describing + * exactly what happened (never throws for ordinary conditions — only truly + * unexpected I/O errors propagate, and the thin CLI wrapper swallows those so + * a hook failure never blocks a session). + * + * @param {object} opts + * @param {string} [opts.homeDir] home directory (default os.homedir()) + * @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env) + * @param {string} [opts.platform] process.platform override (tests) + * @param {object} [opts.env] environment (default process.env) + * @returns {{status:string, canonicalDir?:string, bundledTree?:string, + * linked?:string[], prunedStale?:string[], preserved?:string[], + * skipped?:string[], reason?:string}} + */ +function ensureCanonicalPath(opts = {}) { + const env = opts.env || process.env; + const homeDir = opts.homeDir || os.homedir(); + const platform = opts.platform || process.platform; + const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT; + + // Uniform result contract: every return carries the four action arrays so + // callers can read result.linked/etc without first switching on status. + const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] }; + + // No plugin context → classic/npm install or non-plugin runtime. No-op. + const bundledTree = resolveBundledTree(pluginRoot); + if (!bundledTree) { + return { status: 'noop', reason: 'no-plugin-bundle', ...empty }; + } + + const configDir = resolveConfigDir(homeDir, env); + const canonicalDir = path.join(configDir, 'gsd-core'); + + // Inspect the canonical path itself exactly once. + // - If it is a SYMLINK, the user (or another tool) deliberately pointed the + // canonical path elsewhere. We must NOT write managed links *through* that + // symlink into a directory we do not own — bail as a no-op. + // - If it is a REAL directory with at least one REAL (non-link) managed + // subdir, this is a classic `bin/install.js` install — leave it alone. + let canonicalStat = null; + try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; } + + if (canonicalStat && canonicalStat.isSymbolicLink()) { + return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty }; + } + + if (canonicalStat && canonicalStat.isDirectory()) { + for (const sub of MANAGED_SUBDIRS) { + try { + const subSt = fs.lstatSync(path.join(canonicalDir, sub)); + if (subSt.isDirectory() && !subSt.isSymbolicLink()) { + return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty }; + } + } catch (_) { /* subdir absent — keep checking */ } + } + } + + // Ensure the canonical directory exists (as a real directory). We never + // replace an existing real directory; recursive mkdir is a no-op if present. + try { + fs.mkdirSync(canonicalDir, { recursive: true }); + } catch (e) { + return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty }; + } + + const linked = []; + const prunedStale = []; + const preserved = []; + const skipped = []; + + // SECURITY: prefix used to confirm every per-subdir link target resolves + // strictly inside the bundled tree. Defence-in-depth against a tampered + // bundle that ships an internally-escaping symlink at /. + const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep; + + for (const sub of MANAGED_SUBDIRS) { + const target = path.join(bundledTree, sub); + // Only expose subdirs the bundle actually ships, AND only when the target + // resolves to a real directory that stays inside the bundled tree. A + // subdir whose realpath escapes the bundle (e.g. a planted symlink) is + // skipped — we never point the canonical path at content outside the + // validated plugin bundle. + let targetIsDir = false; + try { + const targetReal = fs.realpathSync(target); + // A NAMED subdir must resolve strictly BELOW the bundled tree root. We do + // NOT accept targetReal === bundledTree here: a subdir that self-links to + // the tree root would otherwise be exposed at the wrong level (e.g. + // `workflows` -> the whole tree), making `@.../workflows/foo` resolve to + // `/foo` instead of `/workflows/foo`. + targetIsDir = fs.statSync(targetReal).isDirectory() + && targetReal.startsWith(bundledWithSep); + } catch (_) { targetIsDir = false; } + if (!targetIsDir) { + skipped.push(sub); + continue; + } + + const linkPath = path.join(canonicalDir, sub); + + // Already a correct link → idempotent no-op. + if (linkPointsAt(linkPath, target)) { + linked.push(sub); + continue; + } + + let existing = null; + try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; } + + if (existing) { + // lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows + // junctions, so this single predicate identifies every GSD-managed link. + if (existing.isSymbolicLink()) { + // A GSD-managed link that is stale or points elsewhere (e.g. previous + // plugin version after `claude plugin update`). Prune and recreate. + try { + fs.unlinkSync(linkPath); + prunedStale.push(sub); + } catch (e) { + skipped.push(sub); + continue; + } + } else { + // A REAL file or directory the user (or a classic install) owns. NEVER + // clobber it — preserve it untouched. This is the USER-PROFILE.md / + // partially-real-canonical-dir safety case. + preserved.push(sub); + continue; + } + } + + try { + createDirLink(target, linkPath, platform); + linked.push(sub); + } catch (e) { + skipped.push(sub); + } + } + + return { + status: 'ensured', + canonicalDir, + bundledTree, + linked, + prunedStale, + preserved, + skipped, + }; +} + +module.exports = { + ensureCanonicalPath, + resolveBundledTree, + resolveConfigDir, + dirLinkType, + MANAGED_SUBDIRS, +}; + +// CLI entry: run on SessionStart. Never block the session — any unexpected +// failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep +// the hook silent in normal operation. +if (require.main === module) { + try { + ensureCanonicalPath(); + } catch (_) { + // Best-effort: a canonical-path failure must never abort a session. + } + process.exit(0); +} diff --git a/hooks/hooks.json b/hooks/hooks.json index 09c8dc0aa..c611277e5 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -3,6 +3,7 @@ "SessionStart": [ { "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js\"", "timeout": 5 }, { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" } ] } diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 12715ed38..1d392471d 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -22,6 +22,7 @@ const MANAGED_HOOKS = [ 'gsd-context-monitor.js', 'gsd-cursor-post-tool.js', 'gsd-cursor-session-start.js', + 'gsd-ensure-canonical-path.js', 'gsd-graphify-update.sh', 'gsd-phase-boundary.sh', 'gsd-prompt-guard.js', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 121a73afb..a4e220860 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -26,6 +26,13 @@ const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`); const HOOKS_TO_COPY = [ 'gsd-check-update-worker.js', 'gsd-check-update.js', + // SessionStart canonical-path bootstrap (#997). In a Claude Code marketplace + // plugin install, ~/.claude/gsd-core is never created, so every + // `@~/.claude/gsd-core/...` include in agents/commands/templates resolves to + // nothing. This hook symlinks the canonical path's immutable subdirs to the + // plugin's bundled gsd-core/ tree; no-op in classic installs. Must ship to + // dist so the installer copies it into the target hooks/ dir. + 'gsd-ensure-canonical-path.js', // Required by gsd-check-update-worker.js at runtime — must ship alongside it // so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir. 'managed-hooks-registry.cjs', diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index 81e5b8d59..b77dccc27 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -33,6 +33,7 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set 'hooks/gsd-context-monitor.js', 'hooks/gsd-cursor-post-tool.js', 'hooks/gsd-cursor-session-start.js', + 'hooks/gsd-ensure-canonical-path.js', 'hooks/gsd-graphify-update.sh', 'hooks/gsd-phase-boundary.sh', 'hooks/gsd-prompt-guard.js', diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 860327ef8..76a80fba4 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -28,6 +28,8 @@ const EXPECTED_ALL_HOOKS = [ 'gsd-check-update.js', 'gsd-config-reload.js', 'gsd-context-monitor.js', + // #997: SessionStart canonical-path bootstrap for plugin installs. + 'gsd-ensure-canonical-path.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 9b8c3ab4a..34cdb8a00 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -13,9 +13,10 @@ * without requiring ajv in devDependencies. */ -const { test, describe } = require('node:test'); +const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -416,19 +417,33 @@ describe('D: always-on hook contract drift guard', () => { return map; } - test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => { + test('SessionStart: one no-matcher group with gsd-ensure-canonical-path.js then gsd-check-update.js', () => { + // #997: gsd-ensure-canonical-path.js is wired alongside gsd-check-update.js + // in the single SessionStart no-matcher group. It must run FIRST so the + // canonical ~/.claude/gsd-core path (and its @-include targets) exist before + // any other SessionStart logic that may read the bundled tree. const map = buildHookMap(); const groups = map['SessionStart']; assert.ok(groups, 'SessionStart must be present in hooks.json'); // There must be exactly one entry group (key '' = no matcher) const noMatcherHooks = groups['']; assert.ok( - Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1, - `SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}` + Array.isArray(noMatcherHooks) && noMatcherHooks.length === 2, + `SessionStart no-matcher group must contain exactly two hooks; got: ${JSON.stringify(noMatcherHooks)}` ); - const h = noMatcherHooks[0]; - assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js'); - assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field'); + assert.equal( + noMatcherHooks[0].script, 'gsd-ensure-canonical-path.js', + 'gsd-ensure-canonical-path.js must be the FIRST SessionStart hook (#997)' + ); + assert.equal( + noMatcherHooks[0].timeout, 5, + 'gsd-ensure-canonical-path.js must have a small timeout (5s) — symlink setup is fast' + ); + assert.equal( + noMatcherHooks[1].script, 'gsd-check-update.js', + 'gsd-check-update.js must remain a SessionStart hook' + ); + assert.equal(noMatcherHooks[1].timeout, undefined, 'gsd-check-update.js must NOT have a timeout field'); }); test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => { @@ -512,3 +527,356 @@ describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => { } }); }); + +// ─── Section F: #997 canonical-path hook registration ──────────────────────── +// +// gsd-ensure-canonical-path.js must be shipped + wired so plugin installs get a +// real ~/.claude/gsd-core directory (with the immutable bundled subdirs +// symlinked) — otherwise every `@~/.claude/gsd-core/...` include in agents / +// commands / templates resolves to nothing and agents fail (#997). +describe('F: #997 gsd-ensure-canonical-path.js is shipped and wired', () => { + const HOOK_BASENAME = 'gsd-ensure-canonical-path.js'; + + test('hook source file exists in hooks/', () => { + assert.ok( + fs.existsSync(path.join(ROOT, 'hooks', HOOK_BASENAME)), + `hooks/${HOOK_BASENAME} must exist on disk` + ); + }); + + test('hook is listed in HOOKS_TO_COPY (build-hooks.js) so it ships to dist', () => { + const { HOOKS_TO_COPY } = require(path.join(ROOT, 'scripts', 'build-hooks.js')); + assert.ok( + HOOKS_TO_COPY.includes(HOOK_BASENAME), + `${HOOK_BASENAME} must be in HOOKS_TO_COPY or it never ships to hooks/dist` + ); + }); + + test('hook is listed in MANAGED_HOOKS (staleness detection)', () => { + assert.ok( + MANAGED_HOOKS.includes(HOOK_BASENAME), + `${HOOK_BASENAME} must be in MANAGED_HOOKS so it is checked for staleness after update` + ); + }); + + test('hook is wired in hooks.json SessionStart with ${CLAUDE_PLUGIN_ROOT}', () => { + const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON_PATH, 'utf-8')); + const sessionStart = hooksConfig.hooks.SessionStart || []; + let wired = false; + for (const entry of sessionStart) { + for (const hook of entry.hooks || []) { + if (hook.command && hook.command.includes(HOOK_BASENAME)) { + wired = true; + assert.ok( + hook.command.includes('${CLAUDE_PLUGIN_ROOT}'), + 'canonical-path hook command must use ${CLAUDE_PLUGIN_ROOT}' + ); + } + } + } + assert.ok(wired, `${HOOK_BASENAME} must be wired under SessionStart in hooks.json`); + }); +}); + +// ─── Section G: #997 ensureCanonicalPath() behavioral regression ───────────── +// +// Drives the hook's exported pure core with fake home / fake plugin-root layouts +// to prove the actual canonical-path bootstrap behaviour: creates symlinks for a +// plugin layout, no-ops for classic installs, preserves user files, prunes stale +// links (self-heal after `claude plugin update`), and handles boundary cases +// (missing bundled dir, pre-existing real dir, pre-existing user file at a link +// target). Behavioral — calls the exported function and asserts the resulting +// filesystem state, not source text. +describe('G: #997 ensureCanonicalPath() behavioural regression', () => { + const { ensureCanonicalPath, dirLinkType, MANAGED_SUBDIRS } = + require(path.join(ROOT, 'hooks', 'gsd-ensure-canonical-path.js')); + + test('win32 uses a junction; other platforms use a dir symlink', () => { + // Junction correctness is an explicit requirement but real junctions can + // only be created on Windows. Assert the platform→fs.symlinkSync type + // mapping directly so the win32 branch is covered on any host. + assert.equal(dirLinkType('win32'), 'junction', 'win32 must use a junction'); + assert.equal(dirLinkType('linux'), 'dir', 'POSIX must use a dir symlink'); + assert.equal(dirLinkType('darwin'), 'dir', 'POSIX must use a dir symlink'); + }); + + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-997-')); + }); + afterEach(() => { + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- per-test temp cleanup, swallows ENOENT + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + // Build a fake plugin layout: /plugin/gsd-core//marker.md and a + // separate fake home /home with an (initially absent) .claude dir. + function makePluginLayout(subdirs = MANAGED_SUBDIRS) { + const pluginRoot = path.join(tmp, 'plugin'); + const bundled = path.join(pluginRoot, 'gsd-core'); + for (const sub of subdirs) { + fs.mkdirSync(path.join(bundled, sub), { recursive: true }); + fs.writeFileSync(path.join(bundled, sub, 'marker.md'), `bundled ${sub}`); + } + const homeDir = path.join(tmp, 'home'); + fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true }); + return { pluginRoot, homeDir, bundled }; + } + + test('plugin layout: creates ~/.claude/gsd-core with all subdirs symlinked to the bundle', () => { + const { pluginRoot, homeDir, bundled } = makePluginLayout(); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + + assert.equal(result.status, 'ensured', `expected ensured; got ${JSON.stringify(result)}`); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + assert.ok(fs.existsSync(canonical), 'canonical dir must exist'); + + for (const sub of MANAGED_SUBDIRS) { + const linkPath = path.join(canonical, sub); + const st = fs.lstatSync(linkPath); + assert.ok(st.isSymbolicLink(), `${sub} must be a symlink`); + assert.equal( + fs.realpathSync(linkPath), + fs.realpathSync(path.join(bundled, sub)), + `${sub} link must resolve to the bundled subdir` + ); + // The @-include target now resolves to real bundled content. + assert.equal( + fs.readFileSync(path.join(linkPath, 'marker.md'), 'utf-8'), + `bundled ${sub}`, + `@-include into ${sub} must resolve to bundled content (this is the #997 fix)` + ); + } + assert.deepEqual(result.linked.sort(), [...MANAGED_SUBDIRS].sort()); + }); + + test('idempotent: a second run with the same layout re-affirms links and changes nothing', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + const second = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(second.status, 'ensured'); + assert.deepEqual(second.linked.sort(), [...MANAGED_SUBDIRS].sort()); + assert.deepEqual(second.prunedStale, []); + assert.deepEqual(second.preserved, []); + }); + + test('classic install: real bundled subdirs at canonical path → no-op (never touched)', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + // Simulate a classic bin/install.js layout: canonical dir is a REAL dir with + // REAL subdirs (not symlinks). + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + for (const sub of MANAGED_SUBDIRS) { + fs.mkdirSync(path.join(canonical, sub), { recursive: true }); + fs.writeFileSync(path.join(canonical, sub, 'real.md'), `classic ${sub}`); + } + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'noop'); + assert.equal(result.reason, 'classic-install'); + for (const sub of MANAGED_SUBDIRS) { + const st = fs.lstatSync(path.join(canonical, sub)); + assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${sub} must stay a real dir`); + } + }); + + test('no plugin context: CLAUDE_PLUGIN_ROOT unset → no-op (classic/npm install path)', () => { + const { homeDir } = makePluginLayout(); + const result = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'noop'); + assert.equal(result.reason, 'no-plugin-bundle'); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core')), 'must not create canonical dir'); + }); + + test('boundary: bundled gsd-core dir missing under plugin root → no-op', () => { + const pluginRoot = path.join(tmp, 'plugin-empty'); + fs.mkdirSync(pluginRoot, { recursive: true }); // no gsd-core/ inside + const homeDir = path.join(tmp, 'home'); + fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true }); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'noop'); + assert.equal(result.reason, 'no-plugin-bundle'); + }); + + test('preserve: a real user file at a managed link target is never clobbered', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + fs.mkdirSync(canonical, { recursive: true }); + // User (or partial state) put a REAL directory at 'references' with content. + fs.mkdirSync(path.join(canonical, 'references'), { recursive: true }); + fs.writeFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'precious'); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + // 'references' is a real dir → classic detection kicks in and the whole op + // is a no-op, preserving everything. Either way, the user file survives. + assert.ok( + fs.existsSync(path.join(canonical, 'references', 'USER-NOTES.md')), + 'user file under a managed target must survive' + ); + assert.equal( + fs.readFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'utf-8'), + 'precious' + ); + void result; + }); + + test('preserve user-generated top-level file (USER-PROFILE.md) while linking subdirs', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + fs.mkdirSync(canonical, { recursive: true }); + // A user-generated file at the TOP of the canonical dir (not a managed + // subdir) — must never be removed. No managed subdir is real yet, so the + // hook proceeds to link them. + fs.writeFileSync(path.join(canonical, 'USER-PROFILE.md'), 'my profile'); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'ensured'); + assert.ok( + fs.existsSync(path.join(canonical, 'USER-PROFILE.md')), + 'USER-PROFILE.md must survive canonical-path setup' + ); + assert.equal(fs.readFileSync(path.join(canonical, 'USER-PROFILE.md'), 'utf-8'), 'my profile'); + // And subdirs are still linked. + for (const sub of MANAGED_SUBDIRS) { + assert.ok(fs.lstatSync(path.join(canonical, sub)).isSymbolicLink(), `${sub} linked`); + } + }); + + test('self-heal: a stale symlink (pointing at a removed prior plugin version) is pruned and recreated', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + fs.mkdirSync(canonical, { recursive: true }); + // Simulate a stale link left by a previous plugin version that has since + // been removed (claude plugin update rotated the version dir). + const stalePrior = path.join(tmp, 'plugin-OLD', 'gsd-core', 'references'); + fs.mkdirSync(stalePrior, { recursive: true }); + const linkPath = path.join(canonical, 'references'); + fs.symlinkSync(stalePrior, linkPath, 'dir'); + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- simulate removed prior version + fs.rmSync(path.join(tmp, 'plugin-OLD'), { recursive: true, force: true }); + assert.ok(!fs.existsSync(linkPath), 'precondition: link now dangles (target removed)'); + + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'ensured'); + assert.ok(result.prunedStale.includes('references'), 'stale references link must be pruned'); + // Now resolves to the CURRENT bundle. + assert.equal( + fs.realpathSync(linkPath), + fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'references')), + 'references must now point at the current bundled tree' + ); + }); + + test('self-heal: a managed symlink pointing at the wrong (but existing) target is repointed', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + fs.mkdirSync(canonical, { recursive: true }); + // A link to some OTHER real directory (e.g. a different plugin version still + // on disk). It is a valid link but points at the wrong place. + const otherDir = path.join(tmp, 'plugin-OTHER', 'gsd-core', 'workflows'); + fs.mkdirSync(otherDir, { recursive: true }); + fs.symlinkSync(otherDir, path.join(canonical, 'workflows'), 'dir'); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'ensured'); + assert.equal( + fs.realpathSync(path.join(canonical, 'workflows')), + fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'workflows')), + 'workflows must be repointed to the current bundle' + ); + }); + + test('security: bundled gsd-core that symlinks OUTSIDE the plugin root is rejected', () => { + const pluginRoot = path.join(tmp, 'plugin-evil'); + fs.mkdirSync(pluginRoot, { recursive: true }); + // Attacker places a symlink at /gsd-core pointing outside root. + const outside = path.join(tmp, 'OUTSIDE'); + fs.mkdirSync(path.join(outside, 'references'), { recursive: true }); + fs.symlinkSync(outside, path.join(pluginRoot, 'gsd-core'), 'dir'); + const homeDir = path.join(tmp, 'home'); + fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true }); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'noop', 'a bundled tree resolving outside the plugin root must be rejected'); + assert.equal(result.reason, 'no-plugin-bundle'); + assert.ok( + !fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')), + 'must NOT link the canonical path at content outside the plugin root' + ); + }); + + test('CLAUDE_CONFIG_DIR honoured: canonical path is created under the custom config dir', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const customCfg = path.join(tmp, 'custom-cfg'); + fs.mkdirSync(customCfg, { recursive: true }); + const result = ensureCanonicalPath({ + pluginRoot, homeDir, platform: 'linux', + env: { CLAUDE_CONFIG_DIR: customCfg }, + }); + assert.equal(result.status, 'ensured'); + assert.equal(result.canonicalDir, path.join(customCfg, 'gsd-core')); + assert.ok(fs.lstatSync(path.join(customCfg, 'gsd-core', 'references')).isSymbolicLink()); + }); + + test('canonical path is itself a symlink → no-op (never writes links through a user-pointed symlink)', () => { + // A user pointed ~/.claude/gsd-core at some other directory via a symlink. + // The hook must NOT create managed links through it into a dir it does not + // own — it bails as a no-op. + const { pluginRoot, homeDir } = makePluginLayout(); + const userTarget = path.join(tmp, 'user-gsd'); + fs.mkdirSync(userTarget, { recursive: true }); + const canonical = path.join(homeDir, '.claude', 'gsd-core'); + fs.symlinkSync(userTarget, canonical, 'dir'); + + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'noop'); + assert.equal(result.reason, 'canonical-is-symlink'); + // No managed links were written into the user's target directory. + for (const sub of MANAGED_SUBDIRS) { + assert.ok( + !fs.existsSync(path.join(userTarget, sub)), + `must not write ${sub} link through the user symlink` + ); + } + }); + + test('uniform result contract: every status carries the four action arrays', () => { + const { pluginRoot, homeDir } = makePluginLayout(); + const noop = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} }); + for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) { + assert.ok(Array.isArray(noop[k]), `noop result.${k} must be an array, not undefined`); + } + const ensured = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) { + assert.ok(Array.isArray(ensured[k]), `ensured result.${k} must be an array`); + } + }); + + test('security: a bundled subdir that symlinks OUTSIDE the bundle is skipped, not linked', () => { + // Defence-in-depth: even within a (validated) plugin root, a tampered + // bundle that ships /references as a symlink escaping the bundle + // must NOT be exposed at the canonical path. + const { pluginRoot, homeDir, bundled } = makePluginLayout(['workflows']); + // Plant an escaping symlink at /references → outside the bundle. + const outside = path.join(tmp, 'OUTSIDE-references'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'evil.md'), 'evil'); + fs.symlinkSync(outside, path.join(bundled, 'references'), 'dir'); + + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'ensured'); + assert.ok(result.linked.includes('workflows'), 'legit subdir still linked'); + assert.ok(result.skipped.includes('references'), 'escaping subdir must be skipped'); + assert.ok( + !fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')), + 'canonical path must NOT expose the escaping subdir' + ); + }); + + test('partial bundle: only ships some subdirs → links those, skips absent ones', () => { + const { pluginRoot, homeDir } = makePluginLayout(['references', 'workflows']); + const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} }); + assert.equal(result.status, 'ensured'); + assert.deepEqual(result.linked.sort(), ['references', 'workflows']); + assert.deepEqual( + result.skipped.sort(), + ['bin', 'contexts', 'templates'].sort(), + 'subdirs not present in the bundle must be skipped, not errored' + ); + }); +});