fix(#2089): wire adapter into install path + address all review findings
MEDIUM fixes (code review):
- Wire resolveManagedHookEvents + resolveHookScripts + buildHookBusEntries
from imperative-hook-bus.cts into writeCursorHooksJson — the install path
is now truly descriptor-driven (reads hostBehaviors.managedHookEvents),
not a hardcoded constant that happens to match the descriptor. bin/install.js
passes the descriptor list via opts.managedHookEvents.
- buildHookBusEntries is now consumed (was dead code); entry-building is no
longer duplicated inline.
- Remove try/finally from cursor-hook-bus-upgrade.test.cjs test bodies
(violated CONTRIBUTING.md L342; redundant with t.after cleanup).
LOW fixes:
- Remove dead require('fs')/require('path') from gsd-cursor-pre-tool.js
- Fix resolveManagedHookEvents docstring (all-invalid fallback behavior)
- Add src/runtime-hooks-surface.cts to the AC2 source-guard file list
Security review: no CRITICAL/HIGH/MEDIUM findings (3 LOW are pre-existing
#777 baseline patterns, not regressions).
This commit is contained in:
@@ -10004,7 +10004,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
|||||||
// adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
|
// adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
|
||||||
// stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
|
// stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
|
||||||
// the event list from the descriptor-driven adapter module.
|
// the event list from the descriptor-driven adapter module.
|
||||||
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
|
const cursorHookResult = writeCursorHooksJson(targetDir, src, {
|
||||||
|
managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
|
||||||
|
});
|
||||||
if (cursorHookResult.changed) {
|
if (cursorHookResult.changed) {
|
||||||
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
|
console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,9 +22,6 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
|
const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
|
||||||
const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
||||||
const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/;
|
const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/;
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export const GSD_HOOK_MARKER = 'gsd-managed';
|
|||||||
* (backward-compat for descriptors predating #2089).
|
* (backward-compat for descriptors predating #2089).
|
||||||
*
|
*
|
||||||
* Pure: no I/O, never throws. Unknown event names are silently filtered
|
* Pure: no I/O, never throws. Unknown event names are silently filtered
|
||||||
* (fail-closed — an unrecognized event is never registered).
|
* (fail-closed — an unrecognized event is never registered). Falls back to
|
||||||
|
* the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries
|
||||||
|
* are unrecognized (ensures the portable-event floor is always covered).
|
||||||
*
|
*
|
||||||
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
|
* @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
|
||||||
* @returns a deduplicated, validated array of event names
|
* @returns a deduplicated, validated array of event names
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import {
|
||||||
|
CURSOR_HOOK_EVENTS,
|
||||||
|
CURSOR_EVENT_SCRIPT_MAP,
|
||||||
|
resolveManagedHookEvents,
|
||||||
|
resolveHookScripts,
|
||||||
|
buildHookBusEntries,
|
||||||
|
} from './host-integration-adapters/imperative-hook-bus.cjs';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import shellCmdProjection = require('./shell-command-projection.cjs');
|
import shellCmdProjection = require('./shell-command-projection.cjs');
|
||||||
const {
|
const {
|
||||||
@@ -86,18 +93,13 @@ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
|
|||||||
const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
|
const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
|
||||||
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
||||||
|
|
||||||
// The full set of Cursor hook events GSD manages (AC4a upgrade, #2089).
|
// The full set of Cursor hook events GSD manages — sourced from the adapter
|
||||||
// Sourced from the descriptor-driven adapter module
|
// (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary
|
||||||
// (src/host-integration-adapters/imperative-hook-bus.cts). This replaces the
|
// stays closed and first-party. Used by reconcileCursorHooksJson (the
|
||||||
// hardcoded ['sessionStart', 'postToolUse'] pair with the 6-event managed set.
|
// reconciliation scope is always the full set). The install path
|
||||||
const CURSOR_MANAGED_EVENTS = [
|
// (writeCursorHooksJson) resolves a descriptor-driven subset via
|
||||||
'sessionStart',
|
// resolveManagedHookEvents(opts.managedHookEvents).
|
||||||
'postToolUse',
|
const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS;
|
||||||
'preToolUse',
|
|
||||||
'stop',
|
|
||||||
'subagentStart',
|
|
||||||
'subagentStop',
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cline / AGENTS.md constants
|
// Cline / AGENTS.md constants
|
||||||
@@ -1035,6 +1037,7 @@ function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorM
|
|||||||
interface WriteCursorHooksJsonOpts {
|
interface WriteCursorHooksJsonOpts {
|
||||||
absoluteRunner?: string | null;
|
absoluteRunner?: string | null;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
managedHookEvents?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
|
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
|
||||||
@@ -1042,20 +1045,11 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
|||||||
const hooksDir = path.join(targetDir, 'hooks');
|
const hooksDir = path.join(targetDir, 'hooks');
|
||||||
fs.mkdirSync(hooksDir, { recursive: true });
|
fs.mkdirSync(hooksDir, { recursive: true });
|
||||||
|
|
||||||
// AC4a (#2089): install all managed hook scripts, not just sessionStart/postToolUse.
|
// Descriptor-driven event resolution (#2089): the managed event set comes
|
||||||
// The event→script mapping is sourced from the descriptor-driven adapter
|
// from the host descriptor's hostBehaviors.managedHookEvents via the pure
|
||||||
// (src/host-integration-adapters/imperative-hook-bus.cts).
|
// adapter (resolveManagedHookEvents), NOT a hardcoded constant.
|
||||||
const eventScriptMap: Record<string, string> = {
|
const events = resolveManagedHookEvents(opts.managedHookEvents);
|
||||||
sessionStart: GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
const hookScripts = resolveHookScripts(events);
|
||||||
postToolUse: GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
|
||||||
preToolUse: GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
|
|
||||||
stop: GSD_CURSOR_STOP_HOOK_SCRIPT,
|
|
||||||
subagentStart: GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
|
||||||
subagentStop: GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
|
||||||
};
|
|
||||||
const hookScripts = CURSOR_MANAGED_EVENTS
|
|
||||||
.map((ev) => eventScriptMap[ev])
|
|
||||||
.filter((s): s is string => Boolean(s));
|
|
||||||
const srcHooksDir = path.join(src, 'hooks');
|
const srcHooksDir = path.join(src, 'hooks');
|
||||||
const installedScripts = new Set<string>();
|
const installedScripts = new Set<string>();
|
||||||
for (const script of hookScripts) {
|
for (const script of hookScripts) {
|
||||||
@@ -1071,20 +1065,16 @@ function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
||||||
const managedEntries: CursorManagedEntries = {};
|
const commands: Record<string, string | null> = {};
|
||||||
for (const ev of CURSOR_MANAGED_EVENTS) {
|
for (const ev of events) {
|
||||||
const script = eventScriptMap[ev];
|
const script = CURSOR_EVENT_SCRIPT_MAP[ev];
|
||||||
if (script && installedScripts.has(script)) {
|
if (script && installedScripts.has(script)) {
|
||||||
const cmd = buildHookCommand(targetDir, script, hookOpts);
|
commands[ev] = buildHookCommand(targetDir, script, hookOpts);
|
||||||
if (cmd) {
|
} else {
|
||||||
managedEntries[ev] = {
|
commands[ev] = null;
|
||||||
type: 'command',
|
|
||||||
command: cmd,
|
|
||||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const managedEntries = buildHookBusEntries(events, commands) as CursorManagedEntries;
|
||||||
|
|
||||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||||
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||||
|
|||||||
@@ -114,68 +114,60 @@ test('all 6 hook scripts exist under hooks/', () => {
|
|||||||
test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => {
|
test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
||||||
t.after(() => cleanup(tmpDir));
|
t.after(() => cleanup(tmpDir));
|
||||||
try {
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
const managedEntries = {};
|
||||||
const managedEntries = {};
|
for (const ev of EXPECTED_EVENTS) {
|
||||||
for (const ev of EXPECTED_EVENTS) {
|
managedEntries[ev] = {
|
||||||
managedEntries[ev] = {
|
type: 'command',
|
||||||
type: 'command',
|
command: `node /fake/${ev}.js`,
|
||||||
command: `node /fake/${ev}.js`,
|
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
};
|
||||||
};
|
}
|
||||||
}
|
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||||
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
assert.ok(result.changed, 'first write must report changed=true');
|
||||||
assert.ok(result.changed, 'first write must report changed=true');
|
|
||||||
|
|
||||||
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||||
const hookTable = written.hooks;
|
const hookTable = written.hooks;
|
||||||
assert.ok(hookTable && typeof hookTable === 'object');
|
assert.ok(hookTable && typeof hookTable === 'object');
|
||||||
for (const ev of EXPECTED_EVENTS) {
|
for (const ev of EXPECTED_EVENTS) {
|
||||||
assert.ok(Array.isArray(hookTable[ev]),
|
assert.ok(Array.isArray(hookTable[ev]),
|
||||||
`hooks.json must have a ${ev} array`);
|
`hooks.json must have a ${ev} array`);
|
||||||
assert.equal(hookTable[ev].length, 1,
|
assert.equal(hookTable[ev].length, 1,
|
||||||
`${ev} must have exactly 1 managed entry`);
|
`${ev} must have exactly 1 managed entry`);
|
||||||
assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true,
|
assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true,
|
||||||
`${ev} entry must carry the GSD managed marker`);
|
`${ev} entry must carry the GSD managed marker`);
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => {
|
test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-'));
|
||||||
t.after(() => cleanup(tmpDir));
|
t.after(() => cleanup(tmpDir));
|
||||||
try {
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
// Seed with user-owned entries in two events.
|
||||||
// Seed with user-owned entries in two events.
|
const seed = {
|
||||||
const seed = {
|
version: 1,
|
||||||
version: 1,
|
hooks: {
|
||||||
hooks: {
|
sessionStart: [{ type: 'command', command: 'user-start.sh' }],
|
||||||
sessionStart: [{ type: 'command', command: 'user-start.sh' }],
|
preToolUse: [{ type: 'command', command: 'user-pre.sh' }],
|
||||||
preToolUse: [{ type: 'command', command: 'user-pre.sh' }],
|
},
|
||||||
},
|
};
|
||||||
|
fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n');
|
||||||
|
|
||||||
|
const managedEntries = {};
|
||||||
|
for (const ev of EXPECTED_EVENTS) {
|
||||||
|
managedEntries[ev] = {
|
||||||
|
type: 'command',
|
||||||
|
command: `node /gsd/${ev}.js`,
|
||||||
|
[GSD_CURSOR_HOOK_MARKER]: true,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n');
|
|
||||||
|
|
||||||
const managedEntries = {};
|
|
||||||
for (const ev of EXPECTED_EVENTS) {
|
|
||||||
managedEntries[ev] = {
|
|
||||||
type: 'command',
|
|
||||||
command: `node /gsd/${ev}.js`,
|
|
||||||
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
||||||
|
|
||||||
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
||||||
// sessionStart: 1 user + 1 managed
|
|
||||||
assert.equal(written.hooks.sessionStart.length, 2);
|
|
||||||
// preToolUse: 1 user + 1 managed
|
|
||||||
assert.equal(written.hooks.preToolUse.length, 2);
|
|
||||||
// postToolUse: 1 managed only
|
|
||||||
assert.equal(written.hooks.postToolUse.length, 1);
|
|
||||||
} finally {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
}
|
}
|
||||||
|
reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||||
|
// sessionStart: 1 user + 1 managed
|
||||||
|
assert.equal(written.hooks.sessionStart.length, 2);
|
||||||
|
// preToolUse: 1 user + 1 managed
|
||||||
|
assert.equal(written.hooks.preToolUse.length, 2);
|
||||||
|
// postToolUse: 1 managed only
|
||||||
|
assert.equal(written.hooks.postToolUse.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ test('no `runtime === "cursor"` string-equality branch remains in the install so
|
|||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
.replace(/\/\/[^\r\n]*/g, '')
|
.replace(/\/\/[^\r\n]*/g, '')
|
||||||
.replace(/`[^`]*`/g, '');
|
.replace(/`[^`]*`/g, '');
|
||||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) {
|
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) {
|
||||||
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || [];
|
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || [];
|
||||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user