fix: make Cursor hooks transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 00:48:33 -04:00
1 parent 6d36f557b1
commit 6a7fdb7595
15 files changed
+1686 -665

No files matched your search

+79 -288
View File
@@ -1,9 +1,7 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
@@ -11,140 +9,18 @@ import {
symlink,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor.js')).href;
const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href;
const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cursor-io.js')).href;
function runNode(args, env, cwd, input = '') {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd,
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
function runHook(handler, input, environment = {}) {
const executable = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
const args = process.platform === 'win32'
? ['-NoProfile', '-NonInteractive', '-Command', handler.command]
: ['-c', handler.command];
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
env: { ...process.env, ...environment },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.once('error', reject);
child.once('close', (code) => resolve({ code, stdout, stderr }));
child.stdin.end(input);
});
}
async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`);
}
async function runPlugin(fixture, method, argument) {
const source = `
import { cursorPlugin } from ${JSON.stringify(pluginModuleUrl)};
const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT);
const result = await cursorPlugin[process.env.ELYDORA_TEST_METHOD](argument);
if (result !== undefined) console.log(JSON.stringify(result));
`;
return runNode(
['--input-type=module', '--eval', source],
{
HOME: fixture.homeDir,
USERPROFILE: fixture.homeDir,
ELYDORA_TEST_ARGUMENT: JSON.stringify(argument),
ELYDORA_TEST_METHOD: method,
},
fixture.projectDir,
);
}
async function createFixture({ existingConfig, createRuntimes = true, hookSource } = {}) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cursor-'));
const homeDir = path.join(rootDir, "home with spaces and 'quote");
const projectDir = path.join(rootDir, 'project with spaces');
const configPath = path.join(homeDir, '.cursor', 'hooks.json');
const agentDir = path.join(homeDir, '.elydora', 'agent-1');
const guardScriptPath = path.join(agentDir, 'guard.js');
const hookScriptPath = path.join(agentDir, 'hook.js');
await mkdir(projectDir, { recursive: true });
if (createRuntimes) {
await mkdir(agentDir, { recursive: true });
await writeFile(
guardScriptPath,
"process.stdin.resume(); process.stdin.once('end', () => { process.stderr.write('Agent is frozen by Elydora.'); process.exit(2); });\n",
);
await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n');
await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({
agent_id: 'agent-1',
agent_name: 'cursor',
}));
}
if (existingConfig !== undefined) await writeJson(configPath, existingConfig);
return {
agentDir,
configPath,
guardScriptPath,
homeDir,
hookScriptPath,
projectDir,
rootDir,
async install() {
return runPlugin(this, 'install', {
agentName: 'cursor',
agentId: 'agent-1',
guardScriptPath,
hookScriptPath,
});
},
async close() {
await rm(rootDir, { recursive: true, force: true });
},
};
}
function managedHandler(config, event, scriptName) {
return config.hooks?.[event]?.find((handler) => handler.command?.includes(scriptName));
}
function assertNativeHandler(handler) {
assert.deepEqual(Object.keys(handler).sort(), ['command', 'failClosed', 'timeout']);
assert.equal(handler.failClosed, true);
assert.equal(handler.timeout, 10);
assert.match(handler.command, /node(?:\.exe)?/i);
if (process.platform === 'win32') {
assert.match(handler.command, /^& /);
assert.match(handler.command, /; exit \$LASTEXITCODE$/);
} else {
assert.match(handler.command, /^'/);
}
}
function legacyHandler(scriptPath) {
return { command: `node "${scriptPath}"` };
}
import {
assertNativeHandler,
createFixture,
installConfig,
legacyHandler,
managedHandler,
registryModuleUrl,
runPlugin,
writeJson,
} from '../test-support/cursor-test-helpers.mjs';
test('Cursor is registered with the native user hook file', async () => {
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
@@ -155,11 +31,10 @@ test('Cursor is registered with the native user hook file', async () => {
});
});
test('Cursor install preserves user hooks, migrates legacy entries, and is idempotent', async () => {
test('Cursor install preserves hooks, migrates legacy entries, and is idempotent', async () => {
const fixture = await createFixture();
try {
await writeJson(fixture.configPath, {
version: 1,
description: 'user-owned',
hooks: {
sessionStart: [{ command: 'user-session' }],
@@ -168,9 +43,11 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp
legacyHandler(fixture.guardScriptPath),
],
postToolUse: [legacyHandler(fixture.hookScriptPath)],
postToolUseFailure: [legacyHandler(fixture.hookScriptPath)],
},
});
assert.equal((await fixture.install()).code, 0);
const first = await fixture.install();
assert.equal(first.code, 0, first.stderr);
const second = await fixture.install();
assert.equal(second.code, 0, second.stderr);
@@ -181,63 +58,16 @@ test('Cursor install preserves user hooks, migrates legacy entries, and is idemp
assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' });
assert.equal(config.hooks.preToolUse.length, 2);
assert.equal(config.hooks.postToolUse.length, 1);
assert.equal(config.hooks.postToolUseFailure.length, 1);
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
assertNativeHandler(managedHandler(config, 'postToolUse', 'hook.js'));
assertNativeHandler(managedHandler(config, 'postToolUseFailure', 'hook.js'));
} finally {
await fixture.close();
}
});
test('Cursor hooks block freezes and forward official input byte-for-byte', async () => {
const capturePath = path.join(os.tmpdir(), `elydora-cursor-event-${process.pid}-${Date.now()}.json`);
const fixture = await createFixture({
hookSource: `
const fs = require('node:fs');
const chunks = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => fs.writeFileSync(process.env.ELYDORA_CAPTURE, Buffer.concat(chunks)));
`,
});
try {
const install = await fixture.install();
assert.equal(install.code, 0, install.stderr);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const guard = managedHandler(config, 'preToolUse', 'guard.js');
const audit = managedHandler(config, 'postToolUse', 'hook.js');
const prePayload = `${JSON.stringify({
conversation_id: 'conversation-1',
generation_id: 'generation-1',
hook_event_name: 'preToolUse',
tool_name: 'Shell',
tool_input: { command: 'Get-ChildItem', working_directory: fixture.projectDir },
tool_use_id: 'call-1',
cwd: fixture.projectDir,
})}\n`;
const guardResult = await runHook(guard, prePayload);
assert.equal(guardResult.code, 2, guardResult.stderr);
assert.match(guardResult.stderr, /Agent is frozen by Elydora/);
const postPayload = `${JSON.stringify({
conversation_id: 'conversation-1',
generation_id: 'generation-1',
hook_event_name: 'postToolUse',
tool_name: 'Shell',
tool_input: { command: 'Get-ChildItem' },
tool_output: '{"exitCode":0,"stdout":"ok"}',
tool_use_id: 'call-1',
cwd: fixture.projectDir,
duration: 42,
})}\n`;
const auditResult = await runHook(audit, postPayload, { ELYDORA_CAPTURE: capturePath });
assert.equal(auditResult.code, 0, auditResult.stderr);
assert.equal(await readFile(capturePath, 'utf-8'), postPayload);
} finally {
await fixture.close();
await rm(capturePath, { force: true });
}
});
test('Cursor status requires an exact pair, matching identity, and physical runtimes', async () => {
test('Cursor status requires three handlers, matching identity, and every runtime secret', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
@@ -245,25 +75,27 @@ test('Cursor status requires an exact pair, matching identity, and physical runt
assert.equal(status.code, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).installed, true);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
config.hooks.preToolUse[0].failClosed = false;
await writeJson(fixture.configPath, config);
const hooks = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
delete hooks.hooks.postToolUseFailure;
await writeJson(fixture.configPath, hooks);
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).hookConfigured, false);
assert.equal((await fixture.install()).code, 0);
const auditSource = await readFile(fixture.hookScriptPath);
await rm(fixture.hookScriptPath);
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
await writeFile(fixture.hookScriptPath, auditSource);
const runtimeConfigPath = path.join(fixture.agentDir, 'config.json');
await writeJson(runtimeConfigPath, { agent_id: 'another-agent', agent_name: 'cursor' });
const keyPath = path.join(fixture.agentDir, 'private.key');
await rm(keyPath);
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
await writeFile(runtimeConfigPath, '{ malformed');
await writeFile(keyPath, 'restored', { mode: 0o600 });
await writeJson(path.join(fixture.agentDir, 'config.json'), {
agent_id: 'another-agent',
agent_name: 'cursor',
});
status = await runPlugin(fixture, 'status', null);
assert.equal(JSON.parse(status.stdout).installed, false);
await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed');
status = await runPlugin(fixture, 'status', null);
assert.equal(status.code, 1);
assert.match(status.stderr, /parse Elydora runtime config/i);
@@ -279,14 +111,16 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn
try {
assert.equal((await fixture.install()).code, 0);
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
const otherGuard = structuredClone(managedHandler(config, 'preToolUse', 'guard.js'));
const otherAudit = structuredClone(managedHandler(config, 'postToolUse', 'hook.js'));
otherGuard.command = otherGuard.command.replaceAll('agent-1', 'agent-10');
otherAudit.command = otherAudit.command.replaceAll('agent-1', 'agent-10');
config.hooks.preToolUse.push(otherGuard, {
command: 'echo elydora agent-1 guard.js', timeout: 10, failClosed: true,
});
config.hooks.postToolUse.push(otherAudit);
for (const [event, script] of [
['preToolUse', 'guard.js'],
['postToolUse', 'hook.js'],
['postToolUseFailure', 'hook.js'],
]) {
const other = structuredClone(managedHandler(config, event, script));
other.command = other.command.replaceAll('agent-1', 'agent-10');
config.hooks[event].push(other);
}
config.hooks.preToolUse.push({ command: 'user-pre' });
await writeJson(fixture.configPath, config);
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
@@ -295,17 +129,19 @@ test('Cursor uninstall removes exact ownership and preserves user entries', asyn
assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]);
assert.equal(remaining.hooks.preToolUse.length, 2);
assert.equal(remaining.hooks.postToolUse.length, 1);
assert.equal(remaining.hooks.postToolUseFailure.length, 1);
assert.match(remaining.hooks.preToolUse[0].command, /agent-10/);
} finally {
await fixture.close();
}
});
test('Cursor rejects malformed, duplicate, and invalid configs before writes', async () => {
test('Cursor rejects malformed, duplicate, versionless user, and invalid configs before writes', async () => {
const invalidConfigs = [
'{ malformed',
'[]\n',
'{"hooks":{}}\n',
'{"hooks":{"preToolUse":[{"command":"user"}]}}\n',
'{"version":2,"hooks":{}}\n',
'{"version":1,"hooks":null}\n',
'{"version":1,"hooks":{"preToolUse":null}}\n',
@@ -320,54 +156,20 @@ test('Cursor rejects malformed, duplicate, and invalid configs before writes', a
const result = await fixture.install();
assert.equal(result.code, 1, `accepted ${existingConfig}`);
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
await assert.rejects(lstat(path.join(fixture.homeDir, '.elydora')), { code: 'ENOENT' });
} finally {
await fixture.close();
}
}
});
test('Cursor rejects missing and symbolic-link files before config writes', async (t) => {
const missing = await createFixture({ createRuntimes: false });
try {
const result = await missing.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /runtime is missing/i);
await assert.rejects(readFile(missing.configPath), { code: 'ENOENT' });
} finally {
await missing.close();
}
const linkedRuntime = await createFixture();
try {
const target = path.join(linkedRuntime.homeDir, 'guard-target.js');
await writeFile(target, 'process.exit(2);\n');
await rm(linkedRuntime.guardScriptPath);
try {
await symlink(target, linkedRuntime.guardScriptPath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
throw error;
}
const result = await linkedRuntime.install();
assert.equal(result.code, 1);
assert.match(result.stderr, /physical file/i);
await assert.rejects(readFile(linkedRuntime.configPath), { code: 'ENOENT' });
} finally {
await linkedRuntime.close();
}
});
test('Cursor confines both runtimes to the managed agent directory', async () => {
test('Cursor confines runtime scripts to the managed agent directory', async () => {
for (const field of ['guardScriptPath', 'hookScriptPath']) {
const fixture = await createFixture();
try {
const result = await runPlugin(fixture, 'install', {
agentName: 'cursor',
agentId: 'agent-1',
guardScriptPath: fixture.guardScriptPath,
hookScriptPath: fixture.hookScriptPath,
const result = await runPlugin(fixture, 'install', installConfig(fixture, {
[field]: path.join(fixture.homeDir, `unmanaged-${field}.js`),
});
}));
assert.equal(result.code, 1);
assert.match(result.stderr, /managed agent directory/i);
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
@@ -377,17 +179,21 @@ test('Cursor confines both runtimes to the managed agent directory', async () =>
}
});
test('Cursor rejects symbolic-link config and preserves its target', async (t) => {
test('Cursor rejects a symbolic-link config and preserves its target', async (t) => {
const fixture = await createFixture();
try {
const target = path.join(fixture.homeDir, 'cursor-hooks-target.json');
const original = '{"version":1,"hooks":{}}\n';
await mkdir(fixture.homeDir, { recursive: true });
await writeFile(target, original);
await mkdir(path.dirname(fixture.configPath), { recursive: true });
try {
await symlink(target, fixture.configPath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const result = await fixture.install();
@@ -401,12 +207,13 @@ test('Cursor rejects symbolic-link config and preserves its target', async (t) =
});
test('Cursor status rejects symbolic-link runtime files', async (t) => {
for (const name of ['config', 'guard', 'audit']) {
for (const name of ['config', 'key', 'guard', 'audit']) {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const filePath = {
config: path.join(fixture.agentDir, 'config.json'),
key: path.join(fixture.agentDir, 'private.key'),
guard: fixture.guardScriptPath,
audit: fixture.hookScriptPath,
}[name];
@@ -417,7 +224,10 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => {
try {
await symlink(target, filePath);
} catch (error) {
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
if (error?.code === 'EPERM') {
t.skip(`symbolic links unavailable: ${error.message}`);
return;
}
throw error;
}
const status = await runPlugin(fixture, 'status', null);
@@ -429,7 +239,7 @@ test('Cursor status rejects symbolic-link runtime files', async (t) => {
}
});
test('Cursor removes an entirely managed config and leaves absent config absent', async () => {
test('Cursor removes an entirely managed config and leaves an absent config absent', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
@@ -443,48 +253,29 @@ test('Cursor removes an entirely managed config and leaves absent config absent'
}
});
test('Cursor atomic writes are private and leave no staging files', async () => {
test('Cursor installation writes private files and leaves no transaction files', async () => {
const fixture = await createFixture();
try {
assert.equal((await fixture.install()).code, 0);
const files = await readdir(path.dirname(fixture.configPath));
assert.equal(files.some((name) => name.endsWith('.tmp') || name.endsWith('.rollback')), false);
const result = await fixture.install();
assert.equal(result.code, 0, result.stderr);
const names = [
...await readdir(fixture.agentDir),
...await readdir(path.dirname(fixture.configPath)),
];
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false);
if (process.platform !== 'win32') {
assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600);
for (const filePath of [
fixture.configPath,
path.join(fixture.agentDir, 'config.json'),
path.join(fixture.agentDir, 'private.key'),
]) {
assert.equal((await stat(filePath)).mode & 0o777, 0o600);
}
for (const filePath of [fixture.guardScriptPath, fixture.hookScriptPath]) {
assert.equal((await stat(filePath)).mode & 0o777, 0o700);
}
}
} finally {
await fixture.close();
}
});
test('Cursor atomic write detects a concurrent source change', async () => {
const fixture = await createFixture({
existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'original' }] } },
});
const concurrent = '{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}\n';
try {
const source = `
import { writeFile } from 'node:fs/promises';
import { readDocument, writeDocument } from ${JSON.stringify(ioModuleUrl)};
const document = await readDocument();
await writeFile(document.filePath, process.env.ELYDORA_CONCURRENT, 'utf-8');
await writeDocument({
document,
changed: true,
next: '{"version":1,"hooks":{}}\\n',
});
`;
const result = await runNode(
['--input-type=module', '--eval', source],
{ HOME: fixture.homeDir, USERPROFILE: fixture.homeDir, ELYDORA_CONCURRENT: concurrent },
fixture.projectDir,
);
assert.equal(result.code, 1);
assert.match(result.stderr, /changed during update/i);
assert.equal(await readFile(fixture.configPath, 'utf-8'), concurrent);
const files = await readdir(path.dirname(fixture.configPath));
assert.equal(files.some((name) => name.endsWith('.tmp')), false);
} finally {
await fixture.close();
}
});