fix: harden Cursor hooks
This commit is contained in:
1 parent
d5a2dcbe53
commit
6d36f557b1
7 files changed
+1109
-126
No files matched your search
@@ -26,6 +26,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo
|
|||||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Translate guard exit code `2` into Cline's JSON stdout cancellation control and preserve official hook input byte-for-byte.
|
||||||
|
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve native `tool_name`, `tool_input`, and `conversation_id` input, set a ten-second timeout plus `failClosed` on both managed handlers, and propagate guard exit code `2` through the host shell.
|
||||||
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
|
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits and require `/hooks` review after external changes.
|
||||||
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
- Resolve Qwen Code user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ The CLI reads the private key and optional API token through hidden terminal pro
|
|||||||
|
|
||||||
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
||||||
|
|
||||||
|
Cursor installation writes native `preToolUse` and `postToolUse` handlers to `~/.cursor/hooks.json` for both the CLI and IDE. Project and enterprise hook sources remain unchanged. Managed commands use the current Node.js executable, preserve Cursor's JSON input, propagate freeze exit code `2`, and enable `failClosed` with a ten-second timeout.
|
||||||
|
|
||||||
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control.
|
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The guard translates a frozen agent into Cline's JSON stdout cancellation control.
|
||||||
|
|
||||||
Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change.
|
Factory Droid installation preserves the active user source across `~/.factory/hooks.json`, the `hooks` field in `~/.factory/settings.json`, and the legacy `~/.factory/hooks/hooks.json` path. Hook files store events at the document root; settings stores the same event map under `hooks`. Project and organization sources remain unchanged. Droid snapshots hooks for each session, so run `/hooks` after installation to review and apply the external change.
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
parse,
|
||||||
|
parseTree,
|
||||||
|
printParseErrorCode,
|
||||||
|
type Node,
|
||||||
|
type ParseError,
|
||||||
|
} from 'jsonc-parser';
|
||||||
|
|
||||||
|
export const AGENT_KEY = 'cursor';
|
||||||
|
export const CONFIG_FILE = 'hooks.json';
|
||||||
|
export const GUARD_SCRIPT = 'guard.js';
|
||||||
|
export const AUDIT_SCRIPT = 'hook.js';
|
||||||
|
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
|
export type JsonObject = Record<string, unknown>;
|
||||||
|
export type CursorHooks = Record<string, JsonObject[]>;
|
||||||
|
|
||||||
|
export interface CursorDocument {
|
||||||
|
readonly exists: boolean;
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly root: JsonObject;
|
||||||
|
readonly hooks: CursorHooks;
|
||||||
|
readonly raw?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedDocument {
|
||||||
|
readonly document: CursorDocument;
|
||||||
|
readonly changed: boolean;
|
||||||
|
readonly next?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContract {
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly guardPath: string;
|
||||||
|
readonly auditPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedArgument {
|
||||||
|
readonly value: string;
|
||||||
|
readonly next: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function samePath(left: string, right: string): boolean {
|
||||||
|
const normalizedLeft = path.resolve(left);
|
||||||
|
const normalizedRight = path.resolve(right);
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||||
|
: normalizedLeft === normalizedRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAgentId(left: string, right: string): boolean {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? left.toLowerCase() === right.toLowerCase()
|
||||||
|
: left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePosix(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePowerShell(value: string): string {
|
||||||
|
return `'${value.replaceAll("'", "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHandler(scriptPath: string): JsonObject {
|
||||||
|
const command = process.platform === 'win32'
|
||||||
|
? `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`
|
||||||
|
: `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
timeout: HOOK_TIMEOUT_SECONDS,
|
||||||
|
failClosed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||||
|
if (command[start] !== "'") return undefined;
|
||||||
|
const apostrophe = `'"'"'`;
|
||||||
|
let value = '';
|
||||||
|
for (let index = start + 1; index < command.length;) {
|
||||||
|
if (command.startsWith(apostrophe, index)) {
|
||||||
|
value += "'";
|
||||||
|
index += apostrophe.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command[index] === "'") return { value, next: index + 1 };
|
||||||
|
value += command[index];
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||||
|
const executable = readPosixArgument(command, 0);
|
||||||
|
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||||
|
const script = readPosixArgument(command, executable.next + 1);
|
||||||
|
if (!script || script.next !== command.length) return undefined;
|
||||||
|
return [executable.value, script.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||||
|
if (command[start] !== "'") return undefined;
|
||||||
|
let value = '';
|
||||||
|
for (let index = start + 1; index < command.length; index += 1) {
|
||||||
|
if (command[index] !== "'") {
|
||||||
|
value += command[index];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (command[index + 1] === "'") {
|
||||||
|
value += "'";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { value, next: index + 1 };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||||
|
if (!command.startsWith('& ')) return undefined;
|
||||||
|
const executable = readPowerShellArgument(command, 2);
|
||||||
|
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||||
|
const script = readPowerShellArgument(command, executable.next + 1);
|
||||||
|
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||||
|
return [executable.value, script.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyCommand(command: string): readonly [string, string] | undefined {
|
||||||
|
const match = /^(node(?:\.exe)?)\s+"([^"\r\n]+)"$/i.exec(command);
|
||||||
|
return match ? [match[1], match[2]] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeExecutable(filePath: string): boolean {
|
||||||
|
const basename = path.basename(filePath);
|
||||||
|
return basename === 'node' || basename.toLowerCase() === 'node.exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedScriptPath(handler: JsonObject): string | undefined {
|
||||||
|
const keys = Object.keys(handler);
|
||||||
|
if (keys.length === 3
|
||||||
|
&& typeof handler.command === 'string'
|
||||||
|
&& handler.timeout === HOOK_TIMEOUT_SECONDS
|
||||||
|
&& handler.failClosed === true) {
|
||||||
|
const parsed = process.platform === 'win32'
|
||||||
|
? parsePowerShellCommand(handler.command)
|
||||||
|
: parsePosixCommand(handler.command);
|
||||||
|
if (parsed
|
||||||
|
&& path.isAbsolute(parsed[0])
|
||||||
|
&& path.isAbsolute(parsed[1])
|
||||||
|
&& isNodeExecutable(parsed[0])) return parsed[1];
|
||||||
|
}
|
||||||
|
if (keys.length !== 1 || typeof handler.command !== 'string') return undefined;
|
||||||
|
const legacy = parseLegacyCommand(handler.command);
|
||||||
|
return legacy && isNodeExecutable(legacy[0]) && path.isAbsolute(legacy[1])
|
||||||
|
? legacy[1]
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedAgentId(handler: JsonObject, scriptName: string): string | undefined {
|
||||||
|
const scriptPath = managedScriptPath(handler);
|
||||||
|
if (!scriptPath || path.basename(scriptPath) !== scriptName) return undefined;
|
||||||
|
const agentDirectory = path.dirname(scriptPath);
|
||||||
|
if (!samePath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const agentId = path.basename(agentDirectory);
|
||||||
|
return agentId && agentId !== '.' && agentId !== '..' ? agentId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectDuplicateKeys(node: Node | undefined, label: string, location: string[] = []): void {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type === 'object') {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const property of node.children ?? []) {
|
||||||
|
const key = String(property.children?.[0]?.value);
|
||||||
|
const childLocation = [...location, key];
|
||||||
|
if (keys.has(key)) {
|
||||||
|
throw new Error(`${label} contains duplicate field "${childLocation.join('.')}"`);
|
||||||
|
}
|
||||||
|
keys.add(key);
|
||||||
|
rejectDuplicateKeys(property.children?.[1], label, childLocation);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.type === 'array') {
|
||||||
|
for (const child of node.children ?? []) rejectDuplicateKeys(child, label, location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string, label: string): JsonObject {
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
const value: unknown = parse(raw, errors, {
|
||||||
|
allowTrailingComma: false,
|
||||||
|
disallowComments: true,
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const details = errors
|
||||||
|
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||||
|
.join(', ');
|
||||||
|
throw new Error(`Failed to parse ${label}: ${details}`);
|
||||||
|
}
|
||||||
|
if (!isObject(value)) throw new Error(`${label} must contain a JSON object`);
|
||||||
|
rejectDuplicateKeys(parseTree(raw, [], {
|
||||||
|
allowTrailingComma: false,
|
||||||
|
disallowComments: true,
|
||||||
|
}), label);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHooks(value: unknown, label: string): CursorHooks {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
|
||||||
|
const hooks: CursorHooks = {};
|
||||||
|
for (const [event, candidate] of Object.entries(value)) {
|
||||||
|
if (!Array.isArray(candidate)) {
|
||||||
|
throw new Error(`${label} field "hooks.${event}" must be an array`);
|
||||||
|
}
|
||||||
|
hooks[event] = candidate.map((handler, index) => {
|
||||||
|
if (!isObject(handler)) {
|
||||||
|
throw new Error(`${label} handler hooks.${event}[${index}] must be an object`);
|
||||||
|
}
|
||||||
|
return handler;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDocument(filePath: string, raw: string): CursorDocument {
|
||||||
|
const label = `Cursor user hooks at ${filePath}`;
|
||||||
|
const root = parseJsonObject(raw, label);
|
||||||
|
if (root.version !== 1) throw new Error(`${label} must declare version 1`);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
filePath,
|
||||||
|
root,
|
||||||
|
hooks: readHooks(root.hooks, label),
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocument(filePath: string): CursorDocument {
|
||||||
|
return { exists: false, filePath, root: {}, hooks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeManagedHooks(hooks: CursorHooks, agentId?: string): CursorHooks {
|
||||||
|
const next: CursorHooks = Object.fromEntries(
|
||||||
|
Object.entries(hooks).map(([event, handlers]) => [event, [...handlers]]),
|
||||||
|
);
|
||||||
|
for (const [event, scriptName] of [
|
||||||
|
['preToolUse', GUARD_SCRIPT],
|
||||||
|
['postToolUse', AUDIT_SCRIPT],
|
||||||
|
] as const) {
|
||||||
|
const handlers = (next[event] ?? []).filter((handler) => {
|
||||||
|
const managedId = managedAgentId(handler, scriptName);
|
||||||
|
return !managedId || (agentId !== undefined && !sameAgentId(managedId, agentId));
|
||||||
|
});
|
||||||
|
if (handlers.length > 0) next[event] = handlers;
|
||||||
|
else delete next[event];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entirelyManaged(document: CursorDocument): boolean {
|
||||||
|
if (!document.exists
|
||||||
|
|| !Object.keys(document.root).every((key) => key === 'version' || key === 'hooks')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const events = Object.entries(document.hooks);
|
||||||
|
if (events.length === 0) return false;
|
||||||
|
let handlerCount = 0;
|
||||||
|
for (const [event, handlers] of events) {
|
||||||
|
const scriptName = event === 'preToolUse'
|
||||||
|
? GUARD_SCRIPT
|
||||||
|
: event === 'postToolUse'
|
||||||
|
? AUDIT_SCRIPT
|
||||||
|
: undefined;
|
||||||
|
if (!scriptName || handlers.length === 0) return false;
|
||||||
|
handlerCount += handlers.length;
|
||||||
|
if (handlers.some((handler) => !managedAgentId(handler, scriptName))) return false;
|
||||||
|
}
|
||||||
|
return handlerCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDocument(
|
||||||
|
document: CursorDocument,
|
||||||
|
hooks: CursorHooks,
|
||||||
|
): RenderedDocument {
|
||||||
|
if (!document.exists && Object.keys(hooks).length === 0) {
|
||||||
|
return { document, changed: false };
|
||||||
|
}
|
||||||
|
if (Object.keys(hooks).length === 0 && entirelyManaged(document)) {
|
||||||
|
return { document, changed: true };
|
||||||
|
}
|
||||||
|
const root: JsonObject = { ...document.root, version: 1 };
|
||||||
|
if (Object.keys(hooks).length > 0) root.hooks = hooks;
|
||||||
|
else delete root.hooks;
|
||||||
|
const next = `${JSON.stringify(root, null, 2)}\n`;
|
||||||
|
return { document, changed: next !== document.raw, next };
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedIds(handlers: JsonObject[], scriptName: string): Map<string, string> {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const handler of handlers) {
|
||||||
|
const agentId = managedAgentId(handler, scriptName);
|
||||||
|
if (!agentId) continue;
|
||||||
|
const key = process.platform === 'win32' ? agentId.toLowerCase() : agentId;
|
||||||
|
result.set(key, agentId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeContracts(hooks: CursorHooks): RuntimeContract[] {
|
||||||
|
const guards = managedIds(hooks.preToolUse ?? [], GUARD_SCRIPT);
|
||||||
|
const audits = managedIds(hooks.postToolUse ?? [], AUDIT_SCRIPT);
|
||||||
|
const root = path.join(os.homedir(), '.elydora');
|
||||||
|
return [...guards]
|
||||||
|
.filter(([key]) => audits.has(key))
|
||||||
|
.map(([, agentId]) => ({
|
||||||
|
agentId,
|
||||||
|
guardPath: path.join(root, agentId, GUARD_SCRIPT),
|
||||||
|
auditPath: path.join(root, agentId, AUDIT_SCRIPT),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import type { FileHandle } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
CONFIG_FILE,
|
||||||
|
type CursorDocument,
|
||||||
|
type JsonObject,
|
||||||
|
type RenderedDocument,
|
||||||
|
type RuntimeContract,
|
||||||
|
createDocument,
|
||||||
|
isObject,
|
||||||
|
parseDocument,
|
||||||
|
} from './cursor-contract.js';
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asError(error: unknown): Error {
|
||||||
|
return error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return isObject(error) && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cursorConfigPath(): string {
|
||||||
|
return path.join(os.homedir(), '.cursor', CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptionalPhysical(filePath: string, label: string): Promise<string | undefined> {
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await fsp.lstat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return undefined;
|
||||||
|
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`${label} path is not a physical file: ${filePath}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fsp.readFile(filePath, 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDocument(): Promise<CursorDocument> {
|
||||||
|
const filePath = cursorConfigPath();
|
||||||
|
const raw = await readOptionalPhysical(filePath, 'Cursor user hooks');
|
||||||
|
return raw === undefined ? createDocument(filePath) : parseDocument(filePath, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePhysicalDirectory(directory: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Create Cursor hooks directory at ${directory}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await fsp.lstat(directory);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Inspect Cursor hooks directory at ${directory}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`Cursor hooks directory is not a physical directory: ${directory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkOptional(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeExclusive(filePath: string, contents: string): Promise<void> {
|
||||||
|
let handle: FileHandle | undefined;
|
||||||
|
try {
|
||||||
|
handle = await fsp.open(filePath, 'wx', 0o600);
|
||||||
|
await handle.writeFile(contents, 'utf-8');
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = undefined;
|
||||||
|
} catch (error) {
|
||||||
|
const failures = [asError(error)];
|
||||||
|
if (handle) {
|
||||||
|
try {
|
||||||
|
await handle.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
failures.push(asError(closeError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlinkOptional(filePath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
failures.push(asError(cleanupError));
|
||||||
|
}
|
||||||
|
if (failures.length > 1) {
|
||||||
|
throw new AggregateError(failures, 'Stage Cursor user hooks');
|
||||||
|
}
|
||||||
|
throw new Error(`Stage Cursor user hooks: ${errorMessage(error)}`, { cause: failures[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUnchanged(document: CursorDocument): Promise<void> {
|
||||||
|
const current = await readOptionalPhysical(document.filePath, 'Cursor user hooks');
|
||||||
|
if (current !== document.raw) {
|
||||||
|
throw new Error(`Cursor user hooks changed during update: ${document.filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDocument(rendered: RenderedDocument): Promise<void> {
|
||||||
|
if (!rendered.changed) return;
|
||||||
|
await assertUnchanged(rendered.document);
|
||||||
|
if (rendered.next === undefined) {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(rendered.document.filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasErrorCode(error, 'ENOENT')) {
|
||||||
|
throw new Error(
|
||||||
|
`Remove Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
|
||||||
|
{ cause: asError(error) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = path.dirname(rendered.document.filePath);
|
||||||
|
await ensurePhysicalDirectory(directory);
|
||||||
|
const temporaryPath = path.join(
|
||||||
|
directory,
|
||||||
|
`.${path.basename(rendered.document.filePath)}.${randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
let committed = false;
|
||||||
|
let failure: Error | undefined;
|
||||||
|
try {
|
||||||
|
await writeExclusive(temporaryPath, rendered.next);
|
||||||
|
await assertUnchanged(rendered.document);
|
||||||
|
await fsp.rename(temporaryPath, rendered.document.filePath);
|
||||||
|
committed = true;
|
||||||
|
} catch (error) {
|
||||||
|
failure = new Error(
|
||||||
|
`Write Cursor user hooks at ${rendered.document.filePath}: ${errorMessage(error)}`,
|
||||||
|
{ cause: asError(error) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cleanupFailure: Error | undefined;
|
||||||
|
if (!committed) {
|
||||||
|
try {
|
||||||
|
await unlinkOptional(temporaryPath);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupFailure = new Error(
|
||||||
|
`Clean Cursor hook staging file at ${temporaryPath}: ${errorMessage(error)}`,
|
||||||
|
{ cause: asError(error) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failure && cleanupFailure) {
|
||||||
|
throw new AggregateError([failure, cleanupFailure], 'Write Cursor user hooks');
|
||||||
|
}
|
||||||
|
if (failure) throw failure;
|
||||||
|
if (cleanupFailure) throw cleanupFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function physicalFileExists(filePath: string, label: string): Promise<boolean> {
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await fsp.lstat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false;
|
||||||
|
throw new Error(`Inspect ${label} at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`${label} path is not a physical file: ${filePath}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireRuntime(filePath: string, label: string): Promise<void> {
|
||||||
|
if (!filePath) throw new Error(`${label} path is required`);
|
||||||
|
if (!await physicalFileExists(filePath, label)) {
|
||||||
|
throw new Error(`${label} is missing: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRuntimeConfig(filePath: string): Promise<JsonObject | undefined> {
|
||||||
|
const raw = await readOptionalPhysical(filePath, 'Elydora runtime config');
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse Elydora runtime config at ${filePath}: ${errorMessage(error)}`, {
|
||||||
|
cause: asError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isObject(value)) {
|
||||||
|
throw new Error(`Elydora runtime config at ${filePath} must contain a JSON object`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAgentId(left: unknown, right: string): boolean {
|
||||||
|
if (typeof left !== 'string') return false;
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? left.toLowerCase() === right.toLowerCase()
|
||||||
|
: left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runtimeFilesExist(contracts: RuntimeContract[]): Promise<boolean> {
|
||||||
|
for (const contract of contracts) {
|
||||||
|
const runtimeConfig = await readRuntimeConfig(
|
||||||
|
path.join(path.dirname(contract.guardPath), 'config.json'),
|
||||||
|
);
|
||||||
|
if (!runtimeConfig
|
||||||
|
|| runtimeConfig.agent_name !== AGENT_KEY
|
||||||
|
|| !sameAgentId(runtimeConfig.agent_id, contract.agentId)) continue;
|
||||||
|
const files = await Promise.all([
|
||||||
|
physicalFileExists(contract.guardPath, 'Elydora guard runtime'),
|
||||||
|
physicalFileExists(contract.auditPath, 'Elydora audit runtime'),
|
||||||
|
]);
|
||||||
|
if (files.every(Boolean)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+43
-125
@@ -1,151 +1,69 @@
|
|||||||
import fsp from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { resolvePrivateChildDirectory } from '../runtime-paths.js';
|
||||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import {
|
||||||
|
AGENT_KEY,
|
||||||
|
AUDIT_SCRIPT,
|
||||||
|
GUARD_SCRIPT,
|
||||||
|
buildHandler,
|
||||||
|
removeManagedHooks,
|
||||||
|
renderDocument,
|
||||||
|
runtimeContracts,
|
||||||
|
samePath,
|
||||||
|
} from './cursor-contract.js';
|
||||||
|
import {
|
||||||
|
readDocument,
|
||||||
|
requireRuntime,
|
||||||
|
runtimeFilesExist,
|
||||||
|
writeDocument,
|
||||||
|
} from './cursor-io.js';
|
||||||
import { SUPPORTED_AGENTS } from './registry.js';
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
const AGENT_KEY = 'cursor';
|
|
||||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
function resolveConfigDir(): string {
|
|
||||||
// .cursor is project-relative, but for global install use home dir
|
|
||||||
return path.join(os.homedir(), entry.configDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConfigPath(): string {
|
|
||||||
return path.join(resolveConfigDir(), entry.configFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHookCommand(scriptPath: string): string {
|
|
||||||
return `node "${scriptPath}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
|
||||||
if (!cmd.includes('elydora')) return false;
|
|
||||||
if (agentId) {
|
|
||||||
return cmd.includes(agentId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
|
||||||
return arr.filter(
|
|
||||||
(h) => typeof h.command === 'string' && !isElydoraCommand(h.command, agentId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cursorPlugin: AgentPlugin = {
|
export const cursorPlugin: AgentPlugin = {
|
||||||
async install(config: InstallConfig): Promise<void> {
|
async install(config: InstallConfig): Promise<void> {
|
||||||
const configDir = resolveConfigDir();
|
if (!config.agentId) throw new Error('agentId is required');
|
||||||
await fsp.mkdir(configDir, { recursive: true });
|
const document = await readDocument();
|
||||||
|
const runtimeRoot = path.join(os.homedir(), '.elydora');
|
||||||
const configPath = resolveConfigPath();
|
const agentDirectory = resolvePrivateChildDirectory(runtimeRoot, config.agentId);
|
||||||
let settings: Record<string, unknown> = {};
|
const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT);
|
||||||
|
const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT);
|
||||||
try {
|
if (!samePath(config.guardScriptPath, expectedGuard)) {
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
throw new Error(`Elydora guard runtime must use the managed agent directory: ${expectedGuard}`);
|
||||||
settings = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
// Start fresh
|
|
||||||
}
|
}
|
||||||
|
if (!samePath(config.hookScriptPath, expectedAudit)) {
|
||||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
throw new Error(`Elydora audit runtime must use the managed agent directory: ${expectedAudit}`);
|
||||||
settings.hooks = {};
|
|
||||||
}
|
}
|
||||||
const hooks = settings.hooks as Record<string, unknown>;
|
await requireRuntime(expectedGuard, 'Elydora guard runtime');
|
||||||
|
await requireRuntime(expectedAudit, 'Elydora audit runtime');
|
||||||
|
|
||||||
// --- preToolUse (guard — freeze enforcement) ---
|
const hooks = removeManagedHooks(document.hooks);
|
||||||
if (!Array.isArray(hooks.preToolUse)) {
|
hooks.preToolUse = [...(hooks.preToolUse ?? []), buildHandler(expectedGuard)];
|
||||||
hooks.preToolUse = [];
|
hooks.postToolUse = [...(hooks.postToolUse ?? []), buildHandler(expectedAudit)];
|
||||||
}
|
await writeDocument(renderDocument(document, hooks));
|
||||||
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
console.log(` Cursor hooks: ${document.filePath}`);
|
||||||
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
|
|
||||||
hooks.preToolUse = preFiltered;
|
|
||||||
|
|
||||||
// --- postToolUse (audit logging) ---
|
|
||||||
if (!Array.isArray(hooks.postToolUse)) {
|
|
||||||
hooks.postToolUse = [];
|
|
||||||
}
|
|
||||||
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
|
||||||
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
|
|
||||||
hooks.postToolUse = postFiltered;
|
|
||||||
|
|
||||||
settings.hooks = hooks;
|
|
||||||
settings.version = 1;
|
|
||||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(agentId?: string): Promise<void> {
|
async uninstall(agentId?: string): Promise<void> {
|
||||||
const configPath = resolveConfigPath();
|
const document = await readDocument();
|
||||||
|
const hooks = removeManagedHooks(document.hooks, agentId);
|
||||||
let settings: Record<string, unknown>;
|
await writeDocument(renderDocument(document, hooks));
|
||||||
try {
|
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
|
||||||
settings = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
||||||
if (!hooks) return;
|
|
||||||
|
|
||||||
if (Array.isArray(hooks.preToolUse)) {
|
|
||||||
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>, agentId);
|
|
||||||
}
|
|
||||||
if (Array.isArray(hooks.postToolUse)) {
|
|
||||||
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>, agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(): Promise<PluginStatus> {
|
async status(): Promise<PluginStatus> {
|
||||||
const configPath = resolveConfigPath();
|
const document = await readDocument();
|
||||||
|
const contracts = runtimeContracts(document.hooks);
|
||||||
let hookConfigured = false;
|
const hookConfigured = contracts.length > 0;
|
||||||
let hookScriptPath = '';
|
const hookScriptExists = hookConfigured && await runtimeFilesExist(contracts);
|
||||||
try {
|
|
||||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
|
||||||
const settings = JSON.parse(raw);
|
|
||||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
||||||
if (hooks) {
|
|
||||||
const check = (arr: unknown) =>
|
|
||||||
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
|
||||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
|
||||||
);
|
|
||||||
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
|
||||||
|
|
||||||
// Extract hook script path from postToolUse command
|
|
||||||
if (hookConfigured && Array.isArray(hooks.postToolUse)) {
|
|
||||||
for (const h of hooks.postToolUse as Array<Record<string, unknown>>) {
|
|
||||||
const cmd = h.command as string;
|
|
||||||
if (cmd && cmd.includes('elydora')) {
|
|
||||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Config not readable
|
|
||||||
}
|
|
||||||
|
|
||||||
let hookScriptExists = false;
|
|
||||||
if (hookScriptPath) {
|
|
||||||
try {
|
|
||||||
await fsp.access(hookScriptPath);
|
|
||||||
hookScriptExists = true;
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed: hookConfigured && hookScriptExists,
|
installed: hookConfigured && hookScriptExists,
|
||||||
agentName: AGENT_KEY,
|
agentName: AGENT_KEY,
|
||||||
displayName: entry.name,
|
displayName: entry.name,
|
||||||
hookConfigured,
|
hookConfigured,
|
||||||
hookScriptExists,
|
hookScriptExists,
|
||||||
configPath,
|
configPath: document.filePath,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -7,7 +7,7 @@ export interface AgentRegistryEntry {
|
|||||||
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||||
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
|
['augment', { name: 'Augment Code CLI', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||||
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||||
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
|
['cursor', { name: 'Cursor', configDir: '~/.cursor', configFile: 'hooks.json' }],
|
||||||
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||||
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
['kirocli', { name: 'Kiro CLI', configDir: '~/.kiro/hooks', configFile: 'elydora-audit.json' }],
|
||||||
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
['kiroide', { name: 'Kiro IDE', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
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}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Cursor is registered with the native user hook file', async () => {
|
||||||
|
const { SUPPORTED_AGENTS } = await import(registryModuleUrl);
|
||||||
|
assert.deepEqual(SUPPORTED_AGENTS.get('cursor'), {
|
||||||
|
name: 'Cursor',
|
||||||
|
configDir: '~/.cursor',
|
||||||
|
configFile: 'hooks.json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor install preserves user 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' }],
|
||||||
|
preToolUse: [
|
||||||
|
{ command: 'user-pre' },
|
||||||
|
legacyHandler(fixture.guardScriptPath),
|
||||||
|
],
|
||||||
|
postToolUse: [legacyHandler(fixture.hookScriptPath)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const second = await fixture.install();
|
||||||
|
assert.equal(second.code, 0, second.stderr);
|
||||||
|
|
||||||
|
const config = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
assert.equal(config.version, 1);
|
||||||
|
assert.equal(config.description, 'user-owned');
|
||||||
|
assert.deepEqual(config.hooks.sessionStart, [{ command: 'user-session' }]);
|
||||||
|
assert.deepEqual(config.hooks.preToolUse[0], { command: 'user-pre' });
|
||||||
|
assert.equal(config.hooks.preToolUse.length, 2);
|
||||||
|
assert.equal(config.hooks.postToolUse.length, 1);
|
||||||
|
assertNativeHandler(managedHandler(config, 'preToolUse', 'guard.js'));
|
||||||
|
assertNativeHandler(managedHandler(config, 'postToolUse', '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 () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
let status = await runPlugin(fixture, 'status', null);
|
||||||
|
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);
|
||||||
|
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' });
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(JSON.parse(status.stdout).installed, false);
|
||||||
|
|
||||||
|
await writeFile(runtimeConfigPath, '{ malformed');
|
||||||
|
status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(status.code, 1);
|
||||||
|
assert.match(status.stderr, /parse Elydora runtime config/i);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor uninstall removes exact ownership and preserves user entries', async () => {
|
||||||
|
const fixture = await createFixture({
|
||||||
|
existingConfig: { version: 1, hooks: { sessionStart: [{ command: 'keep' }] } },
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
await writeJson(fixture.configPath, config);
|
||||||
|
|
||||||
|
const result = await runPlugin(fixture, 'uninstall', 'agent-1');
|
||||||
|
assert.equal(result.code, 0, result.stderr);
|
||||||
|
const remaining = JSON.parse(await readFile(fixture.configPath, 'utf-8'));
|
||||||
|
assert.deepEqual(remaining.hooks.sessionStart, [{ command: 'keep' }]);
|
||||||
|
assert.equal(remaining.hooks.preToolUse.length, 2);
|
||||||
|
assert.equal(remaining.hooks.postToolUse.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 () => {
|
||||||
|
const invalidConfigs = [
|
||||||
|
'{ malformed',
|
||||||
|
'[]\n',
|
||||||
|
'{"hooks":{}}\n',
|
||||||
|
'{"version":2,"hooks":{}}\n',
|
||||||
|
'{"version":1,"hooks":null}\n',
|
||||||
|
'{"version":1,"hooks":{"preToolUse":null}}\n',
|
||||||
|
'{"version":1,"hooks":{"preToolUse":[null]}}\n',
|
||||||
|
'{"version":1,"version":1,"hooks":{}}\n',
|
||||||
|
'{"version":1,"hooks":{},}\n',
|
||||||
|
];
|
||||||
|
for (const existingConfig of invalidConfigs) {
|
||||||
|
const fixture = await createFixture({ existingConfig });
|
||||||
|
try {
|
||||||
|
const before = await readFile(fixture.configPath, 'utf-8');
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1, `accepted ${existingConfig}`);
|
||||||
|
assert.equal(await readFile(fixture.configPath, 'utf-8'), before);
|
||||||
|
} 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 () => {
|
||||||
|
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,
|
||||||
|
[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' });
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor rejects 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 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}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const result = await fixture.install();
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /physical file/i);
|
||||||
|
assert.equal(await readFile(target, 'utf-8'), original);
|
||||||
|
assert.equal((await lstat(fixture.configPath)).isSymbolicLink(), true);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor status rejects symbolic-link runtime files', async (t) => {
|
||||||
|
for (const name of ['config', 'guard', 'audit']) {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
const filePath = {
|
||||||
|
config: path.join(fixture.agentDir, 'config.json'),
|
||||||
|
guard: fixture.guardScriptPath,
|
||||||
|
audit: fixture.hookScriptPath,
|
||||||
|
}[name];
|
||||||
|
const contents = await readFile(filePath);
|
||||||
|
const target = path.join(fixture.homeDir, `${name}-runtime-target`);
|
||||||
|
await writeFile(target, contents);
|
||||||
|
await rm(filePath);
|
||||||
|
try {
|
||||||
|
await symlink(target, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'EPERM') t.skip(`symbolic links unavailable: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const status = await runPlugin(fixture, 'status', null);
|
||||||
|
assert.equal(status.code, 1);
|
||||||
|
assert.match(status.stderr, /physical file/i);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor removes an entirely managed config and leaves absent config absent', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
try {
|
||||||
|
assert.equal((await fixture.install()).code, 0);
|
||||||
|
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
|
||||||
|
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||||
|
|
||||||
|
assert.equal((await runPlugin(fixture, 'uninstall', 'agent-1')).code, 0);
|
||||||
|
await assert.rejects(readFile(fixture.configPath), { code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cursor atomic writes are private and leave no staging 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);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
assert.equal((await stat(fixture.configPath)).mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user