Skip to content

Commit 807299a

Browse files
fix(doctor): skip shell assignments, probe hook documents before reading, fold Windows path case
- executedPath ignores leading NAME=value words before the command word - readJson and the pinned static validator stat documents first so a FIFO at hooks/hooks.json or a manifest-declared hooks path cannot block Doctor - AB7322 containment folds case on win32 (platform threaded from DoctorOptions) - tests: assignment-prefixed duplicates/stale scripts, FIFO documents at both paths, mixed-case duplicate on win32 vs linux
1 parent 8788311 commit 807299a

5 files changed

Lines changed: 79 additions & 12 deletions

File tree

‎docs/diagnostics.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,8 +712,8 @@ that registration statically and never writes `~/.cursor/hooks.json`.
712712

713713
| Code | Severity | Meaning | Recovery |
714714
| --- | --- | --- | --- |
715-
| `AB7321` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a `{ version, hooks: { <event>: [{ command }] } }` document, or an executed script is absent (`stale`). | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. |
716-
| `AB7322` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory (Cursor would deliver that hook twice), or the file is not a valid hooks document. | Remove the plugin-pointing entries or repair the file; manifest registration alone is sufficient. |
715+
| `AB7321` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a regular file or not a `{ version, hooks: { <event>: [{ command }] } }` document, or an executed script is absent (`stale`). Documents and scripts are probed with `stat` before any read, so a FIFO cannot stall Doctor. | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. |
716+
| `AB7322` | warning | `~/.cursor/hooks.json` registers a command whose executed file (after leading `NAME=value` assignments, `env`, and interpreter options) points into an installed plugin directory — compared on path-component boundaries, case-folded on Windows — so Cursor would deliver that hook twice; or the file is not a valid hooks document. | Remove the plugin-pointing entries or repair the file; manifest registration alone is sufficient. |
717717
| `AB7323` | info / warning / error | A staged marketplace repository under `~/.cursor/agent-bundle/marketplaces/<name>` (from `install cursor --mode marketplace`) is imported by Cursor (matching plugin under `~/.cursor/plugins/cache`; info, `registered`), still awaiting the Customize "Add Plugins from Local Repository" step (warning, `unregistered`), or incomplete (error, `corrupt`: manifests missing or failing the pinned schemas, no resolvable Git HEAD, HEAD naming a commit object that does not exist, or a working tree that differs from committed HEAD — verified read-only through `git cat-file -e` / `git --no-optional-locks status` when `git` is available). | Complete the Customize import, use `--mode local`, or remove the staged directory and rerun the installer. |
718718

719719
The installer side reuses the `AB700x` codes: `AB7002` when `git` is missing

‎packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
1+
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
22
import { dirname, join, relative, resolve } from 'node:path';
33

44
import { Ajv, type ErrorObject } from 'ajv/dist/ajv.js';
@@ -224,6 +224,8 @@ const readDocuments = async (
224224
const file = join(pluginDirectory, contract.path);
225225
let source: string;
226226
try {
227+
// `stat` first: `readFile` on a FIFO or device would block until a writer appears.
228+
if (!(await stat(file)).isFile()) throw new Error(`${contract.path} is not a regular file`);
227229
source = await readFile(file, 'utf8');
228230
} catch (error) {
229231
if (isErrno(error, 'ENOENT')) {

‎packages/agent-bundle/src/install/cursor-hooks-registration.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,13 @@ const valueOptions = new Set([
155155

156156
const powerShell = new Set(['powershell', 'pwsh']);
157157

158+
const shellAssignment = /^[A-Za-z_][A-Za-z0-9_]*=/u;
159+
158160
/** `env [NAME=VALUE]... [OPTION]... COMMAND [ARG]...`: strips the assignments and options ahead of COMMAND. */
159161
const unwrapEnv = (operands: readonly string[]): readonly string[] | undefined => {
160162
for (let index = 0; index < operands.length; index += 1) {
161163
const token = operands[index] ?? '';
162-
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token)) continue;
164+
if (shellAssignment.test(token)) continue;
163165
if (token === '-u' || token === '--unset' || token === '-C' || token === '--chdir') {
164166
index += 1;
165167
continue;
@@ -184,9 +186,11 @@ interface ExecutedPath {
184186
* (`node -e`, `sh -c`, `python -m`) or has no operand. Other arguments (`--output ./state/result.json`) are
185187
* runtime inputs/outputs, never the executed file.
186188
*/
187-
const executedPath = (tokens: readonly string[]): ExecutedPath | undefined => {
189+
const executedPath = (words: readonly string[]): ExecutedPath | undefined => {
190+
// Leading `NAME=value` words are shell assignments (`NODE_ENV=production node ./x.mjs`), not the command.
191+
const tokens = words.slice(words.findIndex((word) => !shellAssignment.test(word)));
188192
const [executable, ...operands] = tokens;
189-
if (executable === undefined) return undefined;
193+
if (executable === undefined || shellAssignment.test(executable)) return undefined;
190194
// Interpreter basenames are matched case-insensitively (`PowerShell.EXE`, `Node.exe` on Windows).
191195
const interpreter = executable.replace(/^.*[\\/]/u, '').replace(/\.exe$/iu, '').toLowerCase();
192196
if (interpreter === 'env') {
@@ -261,18 +265,27 @@ const directory = async (path: string): Promise<boolean> => {
261265
}
262266
};
263267

268+
/**
269+
* Read a JSON document only after `stat` confirms a regular file: opening a FIFO (or a device) with `readFile`
270+
* would block Doctor until a writer appears. Anything that is not a regular file reads as `invalid`.
271+
*/
264272
const readJson = async (path: string): Promise<{ readonly value?: unknown; readonly error?: 'missing' | 'invalid' }> => {
265273
try {
274+
if (!(await stat(path)).isFile()) return { error: 'invalid' };
266275
return { value: JSON.parse(await readFile(path, 'utf8')) as unknown };
267276
} catch (error) {
268277
if (isErrno(error, 'ENOENT')) return { error: 'missing' };
269278
return { error: 'invalid' };
270279
}
271280
};
272281

282+
/** Windows paths are case-insensitive, so containment checks there fold case (Cursor itself runs the same file). */
283+
const caseInsensitivePaths = process.platform === 'win32';
284+
273285
const userHookDuplicates = async (
274286
home: string,
275287
pluginDirectory: string,
288+
foldCase: boolean,
276289
): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly duplicates: readonly string[] }> => {
277290
const userHooksPath = join(home, '.cursor', 'hooks.json');
278291
const document = await readJson(userHooksPath);
@@ -289,11 +302,15 @@ const userHookDuplicates = async (
289302
duplicates: Object.freeze([]),
290303
};
291304
}
305+
const fold = (path: string): string => (foldCase ? path.toLowerCase() : path);
292306
const resolvedPlugin = resolve(pluginDirectory);
307+
const foldedPlugin = fold(resolvedPlugin);
293308
// User hooks run from ~/.cursor (https://cursor.com/docs/hooks), so relative command tokens resolve there.
294309
const userHookCwd = join(home, '.cursor');
295-
const insidePlugin = (candidate: string): boolean =>
296-
candidate === resolvedPlugin || candidate.startsWith(`${resolvedPlugin}/`) || candidate.startsWith(`${resolvedPlugin}\\`);
310+
const insidePlugin = (resolvedCandidate: string): boolean => {
311+
const candidate = fold(resolvedCandidate);
312+
return candidate === foldedPlugin || candidate.startsWith(`${foldedPlugin}/`) || candidate.startsWith(`${foldedPlugin}\\`);
313+
};
297314
// Only the file the user hook executes counts: a plugin-local path passed as data (`--output
298315
// ./plugins/local/foo/state.json`) does not deliver that plugin's hook. `insidePlugin` matches on a path
299316
// component boundary so `plugins/local/foo` does not claim hooks aimed at `plugins/local/foo-tools`.
@@ -329,6 +346,7 @@ const userHookDuplicates = async (
329346
export const inspectCursorPluginHooks = async (
330347
pluginDirectory: string,
331348
home: string,
349+
options: { readonly caseInsensitivePaths?: boolean } = {},
332350
): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly registration: CursorHooksRegistration }> => {
333351
const none: CursorHooksRegistration = Object.freeze({ commands: 0, duplicates: Object.freeze([]), events: Object.freeze([]), state: 'none' });
334352
const manifest = await readJson(join(pluginDirectory, '.cursor-plugin/plugin.json'));
@@ -376,7 +394,7 @@ export const inspectCursorPluginHooks = async (
376394
if (!(await regularFile(script))) missingScripts.push(script);
377395
}
378396
}
379-
const duplicates = await userHookDuplicates(home, pluginDirectory);
397+
const duplicates = await userHookDuplicates(home, pluginDirectory, options.caseInsensitivePaths ?? caseInsensitivePaths);
380398
const registration: CursorHooksRegistration = Object.freeze({
381399
commands: parsed.commands.length,
382400
duplicates: duplicates.duplicates,

‎packages/agent-bundle/src/install/doctor.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ const cursorInventory = async (
608608
home: string,
609609
available: boolean,
610610
git: CursorStagingGit,
611+
platform: NodeJS.Platform,
611612
): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory }> => {
612613
if (!available) return { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') };
613614
const installRoot = join(home, '.cursor', 'plugins', 'local');
@@ -730,7 +731,7 @@ const cursorInventory = async (
730731
const durableState = await inspectDurableState(path, 'cursor');
731732
if (durableState !== undefined) diagnostics.push(...durableState.diagnostics);
732733
const hooks = manifest.manifest === cursorManifestCandidates[0]
733-
? await inspectCursorPluginHooks(path, home)
734+
? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' })
734735
: undefined;
735736
if (hooks !== undefined) diagnostics.push(...hooks.diagnostics);
736737
findings.push({
@@ -1409,7 +1410,7 @@ const doctorHost = async (
14091410
: await probeBinary(host, home, run);
14101411
const git = stagingGit(run);
14111412
const inventoried = host === 'cursor'
1412-
? await cursorInventory(home, probed.probe.status === 'available', git)
1413+
? await cursorInventory(home, probed.probe.status === 'available', git, options.platform ?? process.platform)
14131414
: unknownInventory(host);
14141415
const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics];
14151416
let bundle: DoctorHostReport['bundle'];

‎packages/agent-bundle/tests/doctor.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1441,7 +1441,8 @@ const writeHookedCursorPlugin = async (pluginRoot: string, version = '1.2.3'): P
14411441
it('proves plugin-scoped Cursor hook registration and flags stale, missing, and duplicate delivery', async () => {
14421442
const fixture = await temporaryDoctor();
14431443
const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'hooked-fixture');
1444-
const doctor = () => runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] });
1444+
const doctor = (options: { readonly platform?: NodeJS.Platform } = {}) =>
1445+
runDoctor({ ...options, endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] });
14451446
const hookDiagnostics = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7321' || entry.code === 'AB7322');
14461447
try {
14471448
await writeHookedCursorPlugin(pluginRoot);
@@ -1523,6 +1524,24 @@ it('proves plugin-scoped Cursor hook registration and flags stale, missing, and
15231524
expect(hostReport(await doctor(), 'cursor').inventory.findings[0]?.hooks).toMatchObject({
15241525
duplicates: ['node .\\plugins\\local\\hooked-fixture\\hooks\\stop.mjs'],
15251526
});
1527+
// A leading shell assignment is not the command word.
1528+
await writeJson(join(fixture.home, '.cursor', 'hooks.json'), {
1529+
hooks: { stop: [{ command: 'NODE_ENV=production node ./plugins/local/hooked-fixture/hooks/stop.mjs' }] },
1530+
version: 1,
1531+
});
1532+
expect(hostReport(await doctor(), 'cursor').inventory.findings[0]?.hooks).toMatchObject({
1533+
duplicates: ['NODE_ENV=production node ./plugins/local/hooked-fixture/hooks/stop.mjs'],
1534+
});
1535+
// On Windows the filesystem is case-insensitive, so a differently cased spelling still runs the plugin's file.
1536+
const upperCased = `node ${join(pluginRoot, 'hooks/before-tool.mjs').toUpperCase()}`;
1537+
await writeJson(join(fixture.home, '.cursor', 'hooks.json'), {
1538+
hooks: { preToolUse: [{ command: upperCased }] },
1539+
version: 1,
1540+
});
1541+
expect(hostReport(await doctor({ platform: 'win32' }), 'cursor').inventory.findings[0]?.hooks).toMatchObject({
1542+
duplicates: [upperCased],
1543+
});
1544+
expect(hostReport(await doctor({ platform: 'linux' }), 'cursor').inventory.findings[0]?.hooks).toMatchObject({ duplicates: [] });
15261545
// A plugin-local path passed as data to an unrelated script is not duplicate delivery.
15271546
await writeJson(join(fixture.home, '.cursor', 'hooks.json'), {
15281547
hooks: { stop: [{ command: 'node ./hooks/audit.mjs --output ./plugins/local/hooked-fixture/state/result.json' }] },
@@ -1596,6 +1615,8 @@ it('proves plugin-scoped Cursor hook registration and flags stale, missing, and
15961615
'pwsh -NoProfile -File hooks/gone.mjs',
15971616
'PowerShell.EXE -NoProfile -EXECUTIONPOLICY Bypass -File hooks/gone.mjs',
15981617
'/usr/bin/env node ./hooks/gone.mjs',
1618+
'NODE_ENV=production node hooks/gone.mjs',
1619+
'A=1 B=2 /usr/bin/env node ./hooks/gone.mjs',
15991620
'bun run "${CURSOR_PLUGIN_ROOT}/hooks/gone.mjs"',
16001621
'deno run -A ./hooks/gone.mjs',
16011622
'env FOO=1 -u BAR node hooks/gone.mjs',
@@ -1638,6 +1659,31 @@ it('proves plugin-scoped Cursor hook registration and flags stale, missing, and
16381659
expect(hostReport(missing, 'cursor').inventory.findings[0]?.hooks).toMatchObject({ commands: 0, events: [], state: 'missing' });
16391660
expect(hookDiagnostics(missing)).toEqual([expect.objectContaining({ code: 'AB7321', severity: 'error' })]);
16401661
expect(hookDiagnostics(missing)[0]?.message).toContain('is missing');
1662+
1663+
// A hooks document that is not a regular file (a FIFO would block `readFile` forever) is stale, not a hang —
1664+
// both at the pinned `hooks/hooks.json` path and at a manifest-declared custom path.
1665+
if (process.platform !== 'win32') {
1666+
const mkfifo = (path: string) => new Promise<void>((resolvePromise, reject) => {
1667+
const child = spawn('mkfifo', [path], { stdio: 'ignore' });
1668+
child.on('error', reject);
1669+
child.on('exit', (code) => (code === 0 ? resolvePromise() : reject(new Error(`mkfifo exited ${code}`))));
1670+
});
1671+
await mkfifo(join(pluginRoot, 'hooks', 'hooks.json'));
1672+
const fifo = await doctor();
1673+
expect(hostReport(fifo, 'cursor').inventory.findings[0]?.hooks).toMatchObject({ commands: 0, state: 'stale' });
1674+
expect(hookDiagnostics(fifo)).toEqual([expect.objectContaining({ code: 'AB7321', severity: 'error' })]);
1675+
await rm(join(pluginRoot, 'hooks', 'hooks.json'));
1676+
await writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), {
1677+
description: 'Hooked doctor fixture.',
1678+
hooks: 'custom-hooks.json',
1679+
name: 'hooked-fixture',
1680+
version: '1.2.3',
1681+
});
1682+
await mkfifo(join(pluginRoot, 'custom-hooks.json'));
1683+
const customFifo = await doctor();
1684+
expect(hostReport(customFifo, 'cursor').inventory.findings[0]?.hooks).toMatchObject({ commands: 0, state: 'stale' });
1685+
expect(hookDiagnostics(customFifo)).toEqual([expect.objectContaining({ code: 'AB7321', severity: 'error' })]);
1686+
}
16411687
} finally {
16421688
await fixture.cleanup();
16431689
}

0 commit comments

Comments
 (0)