diff --git a/AGENTS.md b/AGENTS.md index 3c90527..08cc514 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Resolve Claude Code user hooks through `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` when the variable is absent. Match Claude Code's path resolution exactly: relative and empty values resolve from the current working directory, and literal tildes remain path segments. Keep project, local, managed, plugin, skill, and agent hook sources read-only. Validate the complete shipped handler schema, including command exec form, async rewake metadata, HTTP, MCP tool, prompt, and agent handlers. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with ten-second exec-form command handlers. Preserve the complete native snake_case payload and propagate freeze and revocation through exit code `2`. Commit Claude Code's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Reject installation while the user source sets `disableAllHooks`, then require `/hooks` and `claude doctor` verification for higher-scope policy effects. - Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit Gemini CLI's user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects. - Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`. -- 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. +- Resolve Cline 3 file hooks through `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents, `.clinerules/hooks`, and workspace `.cline/hooks` sources read-only. Install exact `PreToolUse.mjs` and `PostToolUse.mjs` files for the CLI `tool_call` and `tool_result` events, preserve the complete hybrid native payload byte-for-byte, and translate guard exit code `2` into a one-line JSON stdout cancellation control. Commit both hook files, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact wrapper and runtime sources, canonical private keys, physical files, and matching runtime identity during status checks, then verify discovery with the current official `@cline/core` loader. - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Register `preToolUse`, `postToolUse`, and `postToolUseFailure`, preserve the complete native payload plus `conversation_id`, and emit valid JSON on successful hook execution. Commit Cursor's config, guard, audit runtime, runtime config, and private key through one rollback-capable transaction. Set a ten-second timeout plus `failClosed` on every managed handler 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. - 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. diff --git a/README.md b/README.md index 607601b..ccf57a5 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Cursor installation writes native `preToolUse`, `postToolUse`, and `postToolUseF Gemini CLI installation writes named, matchless `BeforeTool` and `AfterTool` command hooks to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Empty home overrides select the OS home; relative and literal-tilde values retain Gemini CLI's native path behavior. JSON comments and unrelated settings remain intact. Workspace, system-default, system-override, and extension hook sources remain unchanged and continue loading additively. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Managed handlers preserve the complete native snake_case payload, emit valid JSON on successful execution, propagate freeze and revocation through exit code `2`, and use encoded PowerShell commands on Windows. `hooksConfig.enabled` and `hooksConfig.disabled` remain authoritative. Run `/hooks list` after installation to inspect effective hooks from every source. -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`). Documents, `.clinerules/hooks`, and workspace `.cline/hooks` remain unchanged and continue loading additively. Both hook files, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. The wrappers preserve Cline 3's complete native payload, and the guard emits its documented one-line JSON 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. diff --git a/src/plugins/cline-contract.ts b/src/plugins/cline-contract.ts index e044e6b..7ee9829 100644 --- a/src/plugins/cline-contract.ts +++ b/src/plugins/cline-contract.ts @@ -2,6 +2,8 @@ import os from 'node:os'; import path from 'node:path'; export const AGENT_KEY = 'cline'; +export const GUARD_SCRIPT = 'guard.js'; +export const AUDIT_SCRIPT = 'hook.js'; export const GUARD_FILE_NAME = 'PreToolUse.mjs'; export const AUDIT_FILE_NAME = 'PostToolUse.mjs'; @@ -140,7 +142,7 @@ async function main() { if (hookKind === 'guard' && result.code === 2) { const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.'; - process.stdout.write('HOOK_CONTROL\\t' + JSON.stringify({ cancel: true, errorMessage }) + '\\n'); + process.stdout.write(JSON.stringify({ cancel: true, errorMessage }) + '\\n'); return; } if (result.signal) throw new Error('runtime terminated by signal ' + result.signal); @@ -218,8 +220,8 @@ export function runtimeContract( } validateAgentSegment(guard.agentId); const agentDirectory = path.join(os.homedir(), '.elydora', guard.agentId); - const expectedGuard = path.join(agentDirectory, 'guard.js'); - const expectedAudit = path.join(agentDirectory, 'hook.js'); + const expectedGuard = path.join(agentDirectory, GUARD_SCRIPT); + const expectedAudit = path.join(agentDirectory, AUDIT_SCRIPT); if (!samePath(guard.runtimePath, expectedGuard) || !samePath(audit.runtimePath, expectedAudit)) { throw new Error('Elydora Cline hook metadata references an unexpected runtime path'); } diff --git a/src/plugins/cline-installation.ts b/src/plugins/cline-installation.ts new file mode 100644 index 0000000..e3d2cce --- /dev/null +++ b/src/plugins/cline-installation.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; +import type { InstallConfig } from './base.js'; +import { + AGENT_KEY, + AUDIT_SCRIPT, + GUARD_SCRIPT, + assertWrapperIntegrity, + buildMetadata, + buildWrapper, + type ClineHookFile, + runtimeContract, + sameAgentId, +} from './cline-contract.js'; +import { + commitManagedInstallation, + preflightManagedInstallation, + prepareManagedInstallation, + type ManagedRuntimePaths, + type PreparedManagedInstallation, + type RenameFile, +} from './managed-installation.js'; +import { + commitManagedTransaction, + prepareManagedFileChange, + type PreparedManagedTransaction, +} from './managed-transaction.js'; + +const DISPLAY_NAME = 'Cline'; +const DIRECTORY_LABEL = 'Cline hooks directory'; + +export type ClineRuntimePaths = ManagedRuntimePaths; +export type PreparedClineInstallation = PreparedManagedInstallation; +export type { RenameFile }; + +function hookLocations(files: readonly ClineHookFile[]) { + return files.map((file) => ({ + directoryLabel: DIRECTORY_LABEL, + filePath: file.filePath, + })); +} + +export async function preflightClineInstallation( + config: InstallConfig, + files: readonly ClineHookFile[], +): Promise { + return preflightManagedInstallation({ + agentKey: AGENT_KEY, + hookLocations: hookLocations(files), + config, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function prepareClineInstallation( + config: InstallConfig, + guardFile: ClineHookFile, + auditFile: ClineHookFile, +): Promise { + const paths = await preflightClineInstallation(config, [guardFile, auditFile]); + const guardMetadata = buildMetadata('guard', config.agentId, paths.guardPath); + const auditMetadata = buildMetadata('audit', config.agentId, paths.auditPath); + const guardSource = buildWrapper(guardMetadata); + const auditSource = buildWrapper(auditMetadata); + runtimeContract( + { exists: true, filePath: guardFile.filePath, source: guardSource, metadata: guardMetadata }, + { exists: true, filePath: auditFile.filePath, source: auditSource, metadata: auditMetadata }, + ); + return prepareManagedInstallation({ + agentKey: AGENT_KEY, + displayName: DISPLAY_NAME, + hookSources: [{ + directoryLabel: DIRECTORY_LABEL, + label: 'Cline PreToolUse hook', + filePath: guardFile.filePath, + expectedSource: guardFile.source, + source: guardSource, + }, { + directoryLabel: DIRECTORY_LABEL, + label: 'Cline PostToolUse hook', + filePath: auditFile.filePath, + expectedSource: auditFile.source, + source: auditSource, + }], + config, + auditOptions: { nativePayload: true }, + }, GUARD_SCRIPT, AUDIT_SCRIPT); +} + +export async function commitClineInstallation( + prepared: PreparedClineInstallation, + renameFile?: RenameFile, +): Promise { + await commitManagedInstallation(prepared, renameFile); +} + +export async function prepareClineUninstall( + files: readonly ClineHookFile[], + agentId?: string, +): Promise { + const owned = files.filter((file) => { + const ownedAgentId = file.metadata?.agentId; + return ownedAgentId !== undefined + && (agentId === undefined || sameAgentId(ownedAgentId, agentId)); + }); + for (const file of owned) assertWrapperIntegrity(file); + const changes = await Promise.all(owned.map((file) => prepareManagedFileChange({ + filePath: file.filePath, + label: `Cline ${file.metadata!.kind} hook`, + next: undefined, + mode: 0o600, + expectedSource: file.source, + verifyExpectedSource: true, + }))); + return { + displayName: DISPLAY_NAME, + operation: 'uninstall', + directories: owned.map((file) => ({ + path: path.dirname(file.filePath), + label: DIRECTORY_LABEL, + })), + changes: changes.filter((change) => change !== undefined), + }; +} + +export async function commitClineUninstall( + prepared: PreparedManagedTransaction, + renameFile?: RenameFile, +): Promise { + await commitManagedTransaction(prepared, renameFile); +} diff --git a/src/plugins/cline-io.ts b/src/plugins/cline-io.ts index 2465adf..8ac5c83 100644 --- a/src/plugins/cline-io.ts +++ b/src/plugins/cline-io.ts @@ -1,53 +1,34 @@ -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, + AUDIT_SCRIPT, + GUARD_SCRIPT, type ClineHookFile, type ClineRuntimeContract, parseMetadata, sameAgentId, } from './cline-contract.js'; +import { generateGuardScript } from './guard-template.js'; +import { generateHookScript } from './hook-template.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; +import { parseStrictJsonObject, type JsonObject } from './strict-json.js'; -type JsonObject = Record; - -interface StagedFile { - readonly state: ClineHookFile; - readonly tempPath: string; -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function hasErrorCode(error: unknown, code: string): boolean { - return isObject(error) && error.code === code; -} - -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)); -} +const MAX_SECRET_BYTES = 64 * 1024; +const MAX_CONFIG_BYTES = 512 * 1024; export async function readHookFile(filePath: string): Promise { - let source: string; - try { - source = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return { exists: false, filePath }; - throw new Error(`Read Cline hook at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); + const directory = path.dirname(filePath); + if (!await inspectPhysicalDirectory(directory, 'Cline hooks directory')) { + return { exists: false, filePath }; } + const snapshot = await readPhysicalFile(filePath, 'Cline hook'); + if (!snapshot) return { exists: false, filePath }; return { exists: true, filePath, - source, - metadata: parseMetadata(filePath, source), + source: snapshot.contents, + metadata: parseMetadata(filePath, snapshot.contents), }; } @@ -57,193 +38,91 @@ export function requireAvailableHookFile(file: ClineHookFile): void { } } -async function removeTemporary(tempPath: string): Promise { - try { - await fsp.unlink(tempPath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) throw error; - } +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; } -async function failStage( - handle: FileHandle | undefined, - tempPath: string, - cause: unknown, -): Promise { - const errors = [asError(cause)]; - if (handle) { - try { - await handle.close(); - } catch (error) { - errors.push(asError(error)); - } +function requireString(value: unknown, field: string, configPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Elydora runtime config ${field} is invalid: ${configPath}`); } - try { - await removeTemporary(tempPath); - } catch (error) { - errors.push(asError(error)); - } - const message = `Stage Cline hook at ${tempPath}: ${errorMessage(cause)}`; - if (errors.length > 1) throw new AggregateError(errors, message); - throw new Error(message, { cause: errors[0] }); -} - -async function stageFile(state: ClineHookFile, source: string): Promise { - const directory = path.dirname(state.filePath); - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - const tempPath = path.join(directory, `.${path.basename(state.filePath)}.${randomUUID()}.tmp`); - let handle: FileHandle | undefined; - try { - handle = await fsp.open(tempPath, 'wx', 0o700); - await handle.writeFile(source, 'utf-8'); - await handle.sync(); - await handle.close(); - handle = undefined; - return { state, tempPath }; - } catch (error) { - return failStage(handle, tempPath, error); - } -} - -async function writeAtomic(filePath: string, source: string): Promise { - const staged = await stageFile({ exists: false, filePath }, source); - try { - await fsp.rename(staged.tempPath, filePath); - } catch (error) { - const errors = [asError(error)]; - try { - await removeTemporary(staged.tempPath); - } catch (cleanupError) { - errors.push(asError(cleanupError)); - } - throw new AggregateError(errors, `Restore Cline hook at ${filePath}: ${errorMessage(error)}`); - } -} - -async function rollbackFile(state: ClineHookFile): Promise { - if (state.exists) { - await writeAtomic(state.filePath, state.source!); - return; - } - try { - await fsp.unlink(state.filePath); - } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) throw error; - } -} - -export async function writeHookPair( - guard: { readonly state: ClineHookFile; readonly source: string }, - audit: { readonly state: ClineHookFile; readonly source: string }, -): Promise { - const staged: StagedFile[] = []; - try { - staged.push(await stageFile(guard.state, guard.source)); - staged.push(await stageFile(audit.state, audit.source)); - } catch (error) { - const errors = [asError(error)]; - for (const item of staged) { - try { - await removeTemporary(item.tempPath); - } catch (cleanupError) { - errors.push(asError(cleanupError)); - } - } - throw new AggregateError(errors, `Stage Cline hook pair: ${errorMessage(error)}`); - } - - const committed: StagedFile[] = []; - try { - for (const item of staged) { - await fsp.rename(item.tempPath, item.state.filePath); - committed.push(item); - } - } catch (error) { - const errors = [asError(error)]; - for (const item of [...committed].reverse()) { - try { - await rollbackFile(item.state); - } catch (rollbackError) { - errors.push(asError(rollbackError)); - } - } - for (const item of staged.slice(committed.length)) { - try { - await removeTemporary(item.tempPath); - } catch (cleanupError) { - errors.push(asError(cleanupError)); - } - } - throw new AggregateError(errors, `Write Cline hook pair: ${errorMessage(error)}`); - } -} - -export async function removeOwnedHooks( - files: readonly ClineHookFile[], - agentId?: string, -): Promise { - for (const file of files) { - const ownedAgentId = file.metadata?.agentId; - if (!ownedAgentId || (agentId && !sameAgentId(ownedAgentId, agentId))) continue; - try { - await fsp.unlink(file.filePath); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) continue; - throw new Error(`Remove Cline hook at ${file.filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - } -} - -export async function regularFileExists(filePath: string, label: string): Promise { - try { - return (await fsp.stat(filePath)).isFile(); - } catch (error) { - if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')) return false; - throw new Error(`Read ${label} at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } -} - -export async function requireRuntime(filePath: string, label: string): Promise { - if (!filePath) throw new Error(`${label} path is required`); - if (!await regularFileExists(filePath, label)) throw new Error(`${label} is missing: ${filePath}`); -} - -async function readRuntimeConfig(filePath: string): Promise { - let raw: string; - try { - raw = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - if (hasErrorCode(error, 'ENOENT')) return undefined; - throw new Error(`Read Elydora runtime config at ${filePath}: ${errorMessage(error)}`, { - cause: asError(error), - }); - } - 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 be a JSON object`); return value; } +function validateRuntimeConfig( + config: JsonObject, + contract: ClineRuntimeContract, + configPath: string, +): void { + const supported = new Set(['org_id', 'agent_id', 'kid', 'base_url', 'token', 'agent_name']); + const extra = Object.keys(config).find((key) => !supported.has(key)); + if (extra) throw new Error(`Elydora runtime config has unsupported field "${extra}": ${configPath}`); + requireString(config.org_id, 'org_id', configPath); + requireString(config.kid, 'kid', configPath); + const agentId = requireString(config.agent_id, 'agent_id', configPath); + if (!sameAgentId(agentId, contract.agentId) || config.agent_name !== AGENT_KEY) { + throw new Error(`Elydora runtime identity does not match Cline hooks: ${configPath}`); + } + if (config.token !== undefined) requireString(config.token, 'token', configPath); + const rawBaseUrl = requireString(config.base_url, 'base_url', configPath); + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch (error) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`, { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + if (!['http:', 'https:'].includes(baseUrl.protocol) + || !baseUrl.hostname + || baseUrl.username + || baseUrl.password + || baseUrl.search + || baseUrl.hash) { + throw new Error(`Elydora runtime config base_url is invalid: ${configPath}`); + } +} + +function validatePrivateKey(contents: string, keyPath: string): void { + const bytes = Buffer.from(contents, 'base64url'); + if (bytes.length !== 32 || bytes.toString('base64url') !== contents) { + throw new Error(`Elydora private key is invalid: ${keyPath}`); + } +} + +function validContractPaths(contract: ClineRuntimeContract): boolean { + return samePath(path.dirname(contract.agentDirectory), path.join(os.homedir(), '.elydora')) + && samePath(contract.guardPath, path.join(contract.agentDirectory, GUARD_SCRIPT)) + && samePath(contract.auditPath, path.join(contract.agentDirectory, AUDIT_SCRIPT)); +} + export async function runtimeFilesExist(contract: ClineRuntimeContract): Promise { + if (!validContractPaths(contract)) return false; + const runtimeRoot = path.join(os.homedir(), '.elydora'); + if (!await inspectPhysicalDirectory(runtimeRoot, 'Elydora runtime directory')) return false; + if (!await inspectPhysicalDirectory(contract.agentDirectory, 'Elydora agent runtime directory')) { + return false; + } const configPath = path.join(contract.agentDirectory, 'config.json'); - const config = await readRuntimeConfig(configPath); - if (!config - || config.agent_name !== AGENT_KEY - || typeof config.agent_id !== 'string' - || !sameAgentId(config.agent_id, contract.agentId)) return false; - const existence = await Promise.all([ - regularFileExists(contract.guardPath, 'Elydora guard runtime'), - regularFileExists(contract.auditPath, 'Elydora audit runtime'), + const keyPath = path.join(contract.agentDirectory, 'private.key'); + const [config, key, guard, audit] = await Promise.all([ + readPhysicalFile(configPath, 'Elydora runtime config', MAX_CONFIG_BYTES), + readPhysicalFile(keyPath, 'Elydora private key', MAX_SECRET_BYTES), + readPhysicalFile(contract.guardPath, 'Elydora guard runtime'), + readPhysicalFile(contract.auditPath, 'Elydora audit runtime'), ]); - return existence.every(Boolean); + if (!config || !key || !guard || !audit) return false; + const parsed = parseStrictJsonObject(config.contents, `Elydora runtime config at ${configPath}`); + validateRuntimeConfig(parsed, contract, configPath); + validatePrivateKey(key.contents, keyPath); + return guard.contents === generateGuardScript(AGENT_KEY, contract.agentId) + && audit.contents === generateHookScript( + AGENT_KEY, + contract.agentId, + { nativePayload: true }, + ); } diff --git a/src/plugins/cline.ts b/src/plugins/cline.ts index 80865a3..ada4209 100644 --- a/src/plugins/cline.ts +++ b/src/plugins/cline.ts @@ -1,61 +1,60 @@ import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { AGENT_KEY, resolveHookFiles, runtimeContract } from './cline-contract.js'; import { - AGENT_KEY, - buildMetadata, - buildWrapper, - resolveHookFiles, - runtimeContract, -} from './cline-contract.js'; -import { - readHookFile, - removeOwnedHooks, - requireAvailableHookFile, - requireRuntime, - runtimeFilesExist, - writeHookPair, -} from './cline-io.js'; + commitClineInstallation, + commitClineUninstall, + preflightClineInstallation, + prepareClineInstallation, + prepareClineUninstall, +} from './cline-installation.js'; +import { readHookFile, requireAvailableHookFile, runtimeFilesExist } from './cline-io.js'; import { SUPPORTED_AGENTS } from './registry.js'; const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!; -export const clinePlugin: AgentPlugin = { - async install(config: InstallConfig): Promise { - if (!config.agentId) throw new Error('agentId is required'); - const paths = resolveHookFiles(); - const guardState = await readHookFile(paths.guardPath); - const auditState = await readHookFile(paths.auditPath); - requireAvailableHookFile(guardState); - requireAvailableHookFile(auditState); - await requireRuntime(config.guardScriptPath, 'Elydora guard runtime'); - await requireRuntime(config.hookScriptPath, 'Elydora audit runtime'); +async function readHookPair() { + const paths = resolveHookFiles(); + const [guardFile, auditFile] = await Promise.all([ + readHookFile(paths.guardPath), + readHookFile(paths.auditPath), + ]); + return { paths, guardFile, auditFile }; +} - const guardMetadata = buildMetadata('guard', config.agentId, config.guardScriptPath); - const auditMetadata = buildMetadata('audit', config.agentId, config.hookScriptPath); - const guardSource = buildWrapper(guardMetadata); - const auditSource = buildWrapper(auditMetadata); - runtimeContract( - { exists: true, filePath: paths.guardPath, source: guardSource, metadata: guardMetadata }, - { exists: true, filePath: paths.auditPath, source: auditSource, metadata: auditMetadata }, - ); - await writeHookPair( - { state: guardState, source: guardSource }, - { state: auditState, source: auditSource }, - ); +function requireAvailablePair( + guardFile: Awaited>, + auditFile: Awaited>, +): void { + requireAvailableHookFile(guardFile); + requireAvailableHookFile(auditFile); +} + +export const clinePlugin: AgentPlugin = { + managesRuntime: true, + + async preflightInstall(config: InstallConfig): Promise { + const { guardFile, auditFile } = await readHookPair(); + requireAvailablePair(guardFile, auditFile); + await preflightClineInstallation(config, [guardFile, auditFile]); + }, + + async install(config: InstallConfig): Promise { + const { guardFile, auditFile } = await readHookPair(); + requireAvailablePair(guardFile, auditFile); + const prepared = await prepareClineInstallation(config, guardFile, auditFile); + await commitClineInstallation(prepared); console.log(' Cline: user-level PreToolUse and PostToolUse hooks installed.'); }, async uninstall(agentId?: string): Promise { - const paths = resolveHookFiles(); - const guardState = await readHookFile(paths.guardPath); - const auditState = await readHookFile(paths.auditPath); - await removeOwnedHooks([guardState, auditState], agentId); + const { guardFile, auditFile } = await readHookPair(); + const prepared = await prepareClineUninstall([guardFile, auditFile], agentId); + await commitClineUninstall(prepared); }, async status(): Promise { - const paths = resolveHookFiles(); - const guardState = await readHookFile(paths.guardPath); - const auditState = await readHookFile(paths.auditPath); - const contract = runtimeContract(guardState, auditState); + const { paths, guardFile, auditFile } = await readHookPair(); + const contract = runtimeContract(guardFile, auditFile); const hookConfigured = contract !== undefined; const hookScriptExists = contract ? await runtimeFilesExist(contract) : false; return { diff --git a/test-support/cline-test-helpers.mjs b/test-support/cline-test-helpers.mjs new file mode 100644 index 0000000..6da3177 --- /dev/null +++ b/test-support/cline-test-helpers.mjs @@ -0,0 +1,161 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const VALID_PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url'); +export const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cline.js')).href; +export const contractModuleUrl = pathToFileURL( + path.resolve('dist/plugins/cline-contract.js'), +).href; +export const ioModuleUrl = pathToFileURL(path.resolve('dist/plugins/cline-io.js')).href; +export const installationModuleUrl = pathToFileURL( + path.resolve('dist/plugins/cline-installation.js'), +).href; +export const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; +export const cliPath = path.resolve('dist/cli.js'); + +export function runProcess(command, args, env, cwd, input = '') { + return new Promise((resolve, reject) => { + const childEnvironment = { ...process.env, ...env }; + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete childEnvironment[name]; + } + const child = spawn(command, args, { + cwd, + env: childEnvironment, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + 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); + }); +} + +export function runNode(args, env, cwd, input = '') { + return runProcess(process.execPath, args, env, cwd, input); +} + +export function environment(fixture, clineDir = fixture.clineDir) { + return { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLINE_DIR: clineDir === null ? undefined : clineDir, + }; +} + +export function installConfig(fixture, overrides = {}) { + return { + agentName: 'cline', + orgId: 'org-1', + agentId: fixture.agentId, + privateKey: VALID_PRIVATE_KEY, + kid: 'kid-1', + token: 'token-1', + baseUrl: fixture.baseUrl, + guardScriptPath: fixture.guardScriptPath, + hookScriptPath: fixture.hookScriptPath, + ...overrides, + }; +} + +export async function runPlugin(fixture, method, argument, clineDir = fixture.clineDir) { + const source = ` + import { clinePlugin } from ${JSON.stringify(pluginModuleUrl)}; + const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); + const result = await clinePlugin[process.env.ELYDORA_TEST_METHOD](argument); + if (result !== undefined) console.log(JSON.stringify(result)); + `; + return runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture, clineDir), + ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), + ELYDORA_TEST_METHOD: method, + }, + fixture.projectDir, + ); +} + +async function writeOptional(filePath, contents) { + if (contents === undefined) return; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents, { mode: 0o600 }); +} + +export async function createFixture({ + agentId = 'agent-1', + baseUrl = 'http://127.0.0.1:9', + existingAudit, + existingGuard, +} = {}) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cline-')); + const homeDir = path.join(rootDir, "home with spaces and 'quote %CLINE%"); + const projectDir = path.join(rootDir, 'project with spaces'); + const clineDir = path.join(rootDir, 'custom-cline-home'); + const hooksDir = path.join(clineDir, 'hooks'); + const guardWrapperPath = path.join(hooksDir, 'PreToolUse.mjs'); + const auditWrapperPath = path.join(hooksDir, 'PostToolUse.mjs'); + const agentDir = path.join(homeDir, '.elydora', agentId); + const guardScriptPath = path.join(agentDir, 'guard.js'); + const hookScriptPath = path.join(agentDir, 'hook.js'); + await mkdir(projectDir, { recursive: true }); + await writeOptional(guardWrapperPath, existingGuard); + await writeOptional(auditWrapperPath, existingAudit); + const fixture = { + agentDir, + agentId, + auditWrapperPath, + baseUrl, + clineDir, + guardScriptPath, + guardWrapperPath, + homeDir, + hookScriptPath, + hooksDir, + projectDir, + rootDir, + install(overrides = {}, clineDirOverride = this.clineDir) { + return runPlugin(this, 'install', installConfig(this, overrides), clineDirOverride); + }, + async close() { + await rm(rootDir, { recursive: true, force: true }); + }, + }; + return fixture; +} + +export async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf-8')); +} + +export async function startApiServer() { + const requests = []; + const server = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString('utf-8'); + requests.push({ method: request.method, url: request.url, raw }); + response.writeHead(201, { 'Content-Type': 'application/json' }); + response.end('{"operation":{"accepted":true}}'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} diff --git a/test/cline-official.test.mjs b/test/cline-official.test.mjs new file mode 100644 index 0000000..cea5a43 --- /dev/null +++ b/test/cline-official.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createFixture, + environment, + runNode, +} from '../test-support/cline-test-helpers.mjs'; + +const clineCoreEntry = process.env.ELYDORA_CLINE_CORE_ENTRY; +const clineEntry = process.env.ELYDORA_CLINE_ENTRY; + +test('official Cline 3 loader discovers both managed file hooks', { + skip: clineCoreEntry && clineEntry + ? false + : 'set ELYDORA_CLINE_CORE_ENTRY and ELYDORA_CLINE_ENTRY to official Cline files', +}, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const version = await runNode( + [clineEntry, '--version'], + environment(fixture), + fixture.projectDir, + ); + assert.equal(version.code, 0, version.stderr); + assert.match(version.stdout, /^3\.0\.46\s*$/); + + const source = ` + import { pathToFileURL } from 'node:url'; + const { listHookConfigFiles } = await import( + pathToFileURL(process.env.ELYDORA_CLINE_CORE_ENTRY).href + ); + console.log(JSON.stringify(listHookConfigFiles(process.env.ELYDORA_WORKSPACE))); + `; + const load = await runNode( + ['--input-type=module', '--eval', source], + { + ...environment(fixture), + ELYDORA_CLINE_CORE_ENTRY: clineCoreEntry, + ELYDORA_WORKSPACE: fixture.projectDir, + }, + fixture.projectDir, + ); + assert.equal(load.code, 0, load.stderr); + assert.deepEqual(JSON.parse(load.stdout), [{ + fileName: 'PostToolUse', + hookEventName: 'tool_result', + path: fixture.auditWrapperPath, + }, { + fileName: 'PreToolUse', + hookEventName: 'tool_call', + path: fixture.guardWrapperPath, + }]); + } finally { + await fixture.close(); + } +}); diff --git a/test/cline-plugin.test.mjs b/test/cline-plugin.test.mjs index cb2d09e..9ba12c7 100644 --- a/test/cline-plugin.test.mjs +++ b/test/cline-plugin.test.mjs @@ -1,131 +1,37 @@ import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import os from 'node:os'; +import { + lstat, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { + VALID_PRIVATE_KEY, + cliPath, + createFixture, + environment, + installConfig, + readJson, + registryModuleUrl, + runNode, + runPlugin, + startApiServer, +} from '../test-support/cline-test-helpers.mjs'; -const pluginModuleUrl = pathToFileURL(path.resolve('dist/plugins/cline.js')).href; -const registryModuleUrl = pathToFileURL(path.resolve('dist/plugins/registry.js')).href; -const hookTemplateModuleUrl = pathToFileURL(path.resolve('dist/plugins/hook-template.js')).href; -const cliPath = path.resolve('dist/cli.js'); - -function runNode(args, env, input = '', cwd) { - return new Promise((resolve, reject) => { - const childEnvironment = { ...process.env, ...env }; - for (const [name, value] of Object.entries(env)) { - if (value === undefined) delete childEnvironment[name]; - } - const child = spawn(process.execPath, args, { - cwd, - env: childEnvironment, - 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 assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); } -async function runPlugin(fixture, method, argument, clineDir = fixture.clineDir) { - const source = ` - import { clinePlugin } from ${JSON.stringify(pluginModuleUrl)}; - const argument = JSON.parse(process.env.ELYDORA_TEST_ARGUMENT); - const result = await clinePlugin[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, - CLINE_DIR: clineDir === null ? undefined : clineDir, - ELYDORA_TEST_ARGUMENT: JSON.stringify(argument), - ELYDORA_TEST_METHOD: method, - }, - '', - fixture.workspaceDir, - ); -} - -async function writeOptional(filePath, contents) { - if (contents === undefined) return; - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, contents); -} - -async function createFixture({ - agentId = 'agent-1', - autoInstall = true, - createRuntimes = true, - existingAudit, - existingGuard, - guardSource, - hookSource, -} = {}) { - const rootDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-cline-')); - const homeDir = path.join(rootDir, "home with spaces and 'quote"); - const workspaceDir = path.join(rootDir, 'workspace'); - const clineDir = path.join(rootDir, 'custom-cline-home'); - const hooksDir = path.join(clineDir, 'hooks'); - const guardWrapperPath = path.join(hooksDir, 'PreToolUse.mjs'); - const auditWrapperPath = path.join(hooksDir, 'PostToolUse.mjs'); - const agentDir = path.join(homeDir, '.elydora', agentId); - const guardScriptPath = path.join(agentDir, 'guard.js'); - const hookScriptPath = path.join(agentDir, 'hook.js'); - await mkdir(workspaceDir, { recursive: true }); - if (createRuntimes) { - await mkdir(agentDir, { recursive: true }); - await writeFile( - guardScriptPath, - guardSource ?? "process.stdin.destroy(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n", - ); - await writeFile(hookScriptPath, hookSource ?? 'process.stdin.resume();\n'); - await writeFile(path.join(agentDir, 'config.json'), JSON.stringify({ - agent_id: agentId, - agent_name: 'cline', - })); - } - await writeOptional(guardWrapperPath, existingGuard); - await writeOptional(auditWrapperPath, existingAudit); - - const fixture = { - agentDir, - agentId, - auditWrapperPath, - clineDir, - guardScriptPath, - guardWrapperPath, - homeDir, - hookScriptPath, - hooksDir, - rootDir, - workspaceDir, - async close() { - await rm(rootDir, { recursive: true, force: true }); - }, - }; - fixture.config = { - agentName: 'cline', - agentId, - baseUrl: 'https://api.elydora.com', - guardScriptPath, - hookScriptPath, - }; - if (autoInstall) fixture.installResult = await runPlugin(fixture, 'install', fixture.config); - return fixture; -} - -function parseControl(stdout) { - const line = stdout.trim().split(/\r?\n/).at(-1); - assert.match(line, /^HOOK_CONTROL\t/); - return JSON.parse(line.slice('HOOK_CONTROL\t'.length)); +async function assertNoTransactionFiles(fixture) { + const names = [ + ...await readdir(fixture.agentDir), + ...await readdir(fixture.hooksDir), + ]; + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); } test('Cline is registered in the SDK and CLI', async () => { @@ -135,13 +41,13 @@ test('Cline is registered in the SDK and CLI', async () => { configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs', }); - const fixture = await createFixture({ autoInstall: false }); + const fixture = await createFixture(); try { - const result = await runNode(['--no-warnings', cliPath, 'status'], { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - CLINE_DIR: fixture.clineDir, - }, '', fixture.workspaceDir); + const result = await runNode( + ['--no-warnings', cliPath, 'status'], + environment(fixture), + fixture.projectDir, + ); assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /Cline \(cline\)/); } finally { @@ -149,90 +55,150 @@ test('Cline is registered in the SDK and CLI', async () => { } }); -test('Cline install writes only the native global hooks and is idempotent', async () => { +test('Cline installs all six managed files atomically and idempotently', async () => { const fixture = await createFixture(); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - const originalGuard = await readFile(fixture.guardWrapperPath, 'utf-8'); - const originalAudit = await readFile(fixture.auditWrapperPath, 'utf-8'); - assert.match(originalGuard, /^#!\/usr\/bin\/env node\n\/\/ @elydora-cline-hook /); - assert.match(originalAudit, /^#!\/usr\/bin\/env node\n\/\/ @elydora-cline-hook /); + const first = await fixture.install(); + assert.equal(first.code, 0, first.stderr); + const files = [ + fixture.guardScriptPath, + path.join(fixture.agentDir, 'config.json'), + path.join(fixture.agentDir, 'private.key'), + fixture.hookScriptPath, + fixture.guardWrapperPath, + fixture.auditWrapperPath, + ]; + const sources = new Map(await Promise.all(files.map(async (filePath) => [ + filePath, + await readFile(filePath, 'utf-8'), + ]))); + assert.match(sources.get(fixture.guardWrapperPath), /^#!\/usr\/bin\/env node\n\/\/ @elydora-cline-hook /); + assert.match(sources.get(fixture.auditWrapperPath), /^#!\/usr\/bin\/env node\n\/\/ @elydora-cline-hook /); + assert.equal(sources.get(path.join(fixture.agentDir, 'private.key')), VALID_PRIVATE_KEY); + assert.deepEqual(await readJson(path.join(fixture.agentDir, 'config.json')), { + org_id: 'org-1', + agent_id: fixture.agentId, + kid: 'kid-1', + base_url: fixture.baseUrl, + token: 'token-1', + agent_name: 'cline', + }); - const second = await runPlugin(fixture, 'install', fixture.config); + const second = await fixture.install(); assert.equal(second.code, 0, second.stderr); - assert.equal(await readFile(fixture.guardWrapperPath, 'utf-8'), originalGuard); - assert.equal(await readFile(fixture.auditWrapperPath, 'utf-8'), originalAudit); - assert.equal((await readdir(fixture.hooksDir)).some((name) => name.endsWith('.tmp')), false); - - await assert.rejects( - readFile(path.join(fixture.homeDir, 'Documents', 'Cline', 'Hooks', 'PreToolUse.mjs')), - { code: 'ENOENT' }, - ); - await assert.rejects( - readFile(path.join(fixture.workspaceDir, '.cline', 'hooks', 'PreToolUse.mjs')), - { code: 'ENOENT' }, - ); - await assert.rejects( - readFile(path.join(fixture.workspaceDir, '.clinerules', 'hooks', 'PreToolUse.mjs')), - { code: 'ENOENT' }, - ); + for (const [filePath, source] of sources) { + assert.equal(await readFile(filePath, 'utf-8'), source, filePath); + } + await assertNoTransactionFiles(fixture); + await assertMissing(path.join(fixture.homeDir, 'Documents', 'Cline', 'Hooks', 'PreToolUse.mjs')); + await assertMissing(path.join(fixture.projectDir, '.cline', 'hooks', 'PreToolUse.mjs')); + await assertMissing(path.join(fixture.projectDir, '.clinerules', 'hooks', 'PreToolUse.mjs')); + if (process.platform !== 'win32') { + assert.equal((await stat(fixture.agentDir)).mode & 0o777, 0o700); + for (const filePath of files.slice(0, 4)) { + const expected = filePath.endsWith('.json') || filePath.endsWith('.key') ? 0o600 : 0o700; + assert.equal((await stat(filePath)).mode & 0o777, expected); + } + } } finally { await fixture.close(); } }); -test('Cline install uses the official default when CLINE_DIR is absent', async () => { - const fixture = await createFixture({ autoInstall: false }); - const defaultHooks = path.join(fixture.homeDir, '.cline', 'hooks'); +test('Cline uses the official default when CLINE_DIR is absent', async () => { + const fixture = await createFixture(); try { - const result = await runPlugin(fixture, 'install', fixture.config, null); + const result = await fixture.install({}, null); assert.equal(result.code, 0, result.stderr); + const defaultHooks = path.join(fixture.homeDir, '.cline', 'hooks'); await readFile(path.join(defaultHooks, 'PreToolUse.mjs')); await readFile(path.join(defaultHooks, 'PostToolUse.mjs')); - await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); + await assertMissing(fixture.guardWrapperPath); } finally { await fixture.close(); } }); -test('Cline wrappers translate freezes and forward official payloads byte-for-byte', async () => { - const fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'elydora-cline-capture-')); - const capturePath = path.join(fixtureRoot, 'event.json'); - const hookSource = ` - const fs = require('node:fs'); - const chunks = []; - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ - cwd: process.cwd(), - input: Buffer.concat(chunks).toString('utf-8'), - }))); - `; - const fixture = await createFixture({ hookSource }); +test('Cline wrappers preserve native payload bytes and emit documented cancellation JSON', async () => { + const fixture = await createFixture(); + const capturePath = path.join(fixture.rootDir, 'captured.json'); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.equal((await fixture.install()).code, 0); + await writeFile( + fixture.guardScriptPath, + "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n", + ); const prePayload = JSON.stringify({ clineVersion: '3.0.46', hookName: 'tool_call', taskId: 'task-1', + workspaceRoots: [fixture.projectDir], + model: { provider: 'openai', slug: 'gpt-5.3-codex' }, tool_call: { id: 'call-1', name: 'read_file', input: { path: 'README.md' } }, + preToolUse: { toolName: 'read_file', parameters: { path: 'README.md' } }, }); const guard = await runNode( [fixture.guardWrapperPath], - { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }, + environment(fixture), + fixture.projectDir, prePayload, - fixture.workspaceDir, ); assert.equal(guard.code, 0, guard.stderr); - assert.match(guard.stderr, /Agent is frozen by Elydora/); - assert.deepEqual(parseControl(guard.stdout), { + assert.deepEqual(JSON.parse(guard.stdout), { cancel: true, errorMessage: 'Agent is frozen by Elydora.', }); + await writeFile(fixture.hookScriptPath, ` + const fs = require('node:fs'); + const chunks = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => fs.writeFileSync( + ${JSON.stringify(capturePath)}, + Buffer.concat(chunks), + )); + `); const postPayload = JSON.stringify({ clineVersion: '3.0.46', hookName: 'tool_result', taskId: 'task-1', + tool_result: { name: 'read_file', input: { path: 'README.md' }, output: 'ok' }, + postToolUse: { + toolName: 'read_file', + parameters: { path: 'README.md' }, + result: 'ok', + success: true, + executionTimeMs: 5, + }, + }); + const audit = await runNode( + [fixture.auditWrapperPath], + environment(fixture), + fixture.projectDir, + postPayload, + ); + assert.equal(audit.code, 0, audit.stderr); + assert.equal(audit.stdout, ''); + assert.equal(await readFile(capturePath, 'utf-8'), postPayload); + } finally { + await fixture.close(); + } +}); + +test('Cline audit submits the complete native event with derived action fields', async () => { + const api = await startApiServer(); + const fixture = await createFixture({ baseUrl: api.baseUrl }); + try { + assert.equal((await fixture.install()).code, 0); + const payload = { + clineVersion: '3.0.46', + hookName: 'tool_result', + timestamp: '2026-07-19T12:00:00.000Z', + taskId: 'task-1', + workspaceRoots: [fixture.projectDir], + userId: 'user-1', + agent_id: 'cline-agent', + parent_agent_id: null, tool_result: { id: 'call-1', name: 'read_file', @@ -240,231 +206,188 @@ test('Cline wrappers translate freezes and forward official payloads byte-for-by output: 'ok', durationMs: 5, }, - }); - const audit = await runNode( - [fixture.auditWrapperPath], - { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }, - postPayload, - fixture.workspaceDir, - ); - assert.equal(audit.code, 0, audit.stderr); - assert.equal(audit.stdout, ''); - assert.deepEqual(JSON.parse(await readFile(capturePath, 'utf-8')), { - cwd: fixture.workspaceDir, - input: postPayload, - }); - } finally { - await fixture.close(); - await rm(fixtureRoot, { recursive: true, force: true }); - } -}); - -test('Cline audit runtime maps official nested fields into the submitted operation', async () => { - let resolveOperation; - const operationReceived = new Promise((resolve) => { resolveOperation = resolve; }); - const server = createServer((request, response) => { - const chunks = []; - request.on('data', (chunk) => chunks.push(chunk)); - request.on('end', () => { - resolveOperation(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); - response.writeHead(201, { 'Content-Type': 'application/json' }); - response.end('{}'); - }); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - assert.equal(typeof address, 'object'); - const fixture = await createFixture({ autoInstall: false }); - try { - const { generateHookScript } = await import(hookTemplateModuleUrl); - await writeFile(fixture.hookScriptPath, generateHookScript('cline', fixture.agentId)); - await writeFile(path.join(fixture.agentDir, 'config.json'), JSON.stringify({ - org_id: 'org-1', - agent_id: fixture.agentId, - kid: 'key-1', - base_url: `http://127.0.0.1:${address.port}`, - agent_name: 'cline', - })); - await writeFile(path.join(fixture.agentDir, 'private.key'), Buffer.alloc(32, 1).toString('base64url')); - const install = await runPlugin(fixture, 'install', fixture.config); - assert.equal(install.code, 0, install.stderr); - const payload = JSON.stringify({ - hookName: 'tool_result', - taskId: 'task-1', - tool_result: { - name: 'read_file', - input: { path: 'README.md' }, - output: 'ok', + postToolUse: { + toolName: 'read_file', + parameters: { path: 'README.md' }, + result: 'ok', + success: true, + executionTimeMs: 5, }, - }); - const result = await runNode([fixture.auditWrapperPath], { - HOME: fixture.homeDir, - USERPROFILE: fixture.homeDir, - }, payload, fixture.workspaceDir); + }; + const result = await runNode( + [fixture.auditWrapperPath], + environment(fixture), + fixture.projectDir, + JSON.stringify(payload), + ); assert.equal(result.code, 0, result.stderr); - let timeout; - const operation = await Promise.race([ - operationReceived, - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error('Timed out waiting for audit operation')), 3_000); - }), - ]).finally(() => clearTimeout(timeout)); - assert.deepEqual(operation.payload, { - tool_name: 'read_file', - tool_input: { path: 'README.md' }, - session_id: 'task-1', - }); + assert.equal(api.requests.length, 1); + const operation = JSON.parse(api.requests[0].raw); + assert.deepEqual(operation.payload, payload); assert.deepEqual(operation.action, { tool: 'read_file' }); assert.deepEqual(operation.subject, { session_id: 'task-1' }); } finally { - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); + await api.close(); await fixture.close(); } }); test('Cline wrappers keep pass decisions quiet and surface runtime failures', async () => { - const passing = await createFixture({ guardSource: 'process.stdin.resume();\n' }); + const fixture = await createFixture(); try { - assert.equal(passing.installResult.code, 0, passing.installResult.stderr); - const result = await runNode([passing.guardWrapperPath], {}, '{}', passing.workspaceDir); + assert.equal((await fixture.install()).code, 0); + await writeFile(fixture.guardScriptPath, 'process.stdin.resume();\n'); + let result = await runNode( + [fixture.guardWrapperPath], + environment(fixture), + fixture.projectDir, + '{}', + ); assert.equal(result.code, 0, result.stderr); assert.equal(result.stdout, ''); - } finally { - await passing.close(); - } - const failing = await createFixture({ hookSource: "process.stderr.write('audit failed\\n'); process.exit(7);\n" }); - try { - assert.equal(failing.installResult.code, 0, failing.installResult.stderr); - const result = await runNode([failing.auditWrapperPath], {}, '{}', failing.workspaceDir); + await writeFile( + fixture.hookScriptPath, + "process.stdin.resume(); process.stderr.write('audit failed\\n'); process.exit(7);\n", + ); + result = await runNode( + [fixture.auditWrapperPath], + environment(fixture), + fixture.projectDir, + '{}', + ); assert.equal(result.code, 1); assert.match(result.stderr, /audit failed/); assert.match(result.stderr, /exited with code 7/); - } finally { - await failing.close(); - } -}); - -test('Cline status requires an intact pair and both Elydora runtimes', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - let result = await runPlugin(fixture, 'status', null); - assert.equal(result.code, 0, result.stderr); - assert.deepEqual(JSON.parse(result.stdout), { - installed: true, - agentName: 'cline', - displayName: 'Cline', - hookConfigured: true, - hookScriptExists: true, - configPath: fixture.hooksDir, - }); - - await rm(fixture.guardScriptPath); - result = await runPlugin(fixture, 'status', null); - assert.equal(JSON.parse(result.stdout).installed, false); - await writeFile(fixture.guardScriptPath, 'process.exit(0);\n'); - await rm(fixture.auditWrapperPath); - result = await runPlugin(fixture, 'status', null); - const partial = JSON.parse(result.stdout); - assert.equal(partial.installed, false); - assert.equal(partial.hookConfigured, false); } finally { await fixture.close(); } }); -test('Cline status surfaces corrupt owned hooks and runtime metadata', async () => { - const fixture = await createFixture(); - try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); - await writeFile(fixture.guardWrapperPath, `${await readFile(fixture.guardWrapperPath, 'utf-8')}\n// tampered\n`); - let result = await runPlugin(fixture, 'status', null); - assert.equal(result.code, 1); - assert.match(result.stderr, /managed template/i); - - const reinstall = await runPlugin(fixture, 'install', fixture.config); - assert.equal(reinstall.code, 0, reinstall.stderr); - await writeFile(path.join(fixture.agentDir, 'config.json'), '{ malformed'); - result = await runPlugin(fixture, 'status', null); - assert.equal(result.code, 1); - assert.match(result.stderr, /parse Elydora runtime config/i); - } finally { - await fixture.close(); +test('Cline status requires exact physical hooks, runtimes, config, and key', async (t) => { + const cases = [ + ['guard runtime', 'guard', 'tampered\n', /"installed":false/], + ['runtime config', 'config', '{ malformed', /parse Elydora runtime config/i], + ['private key', 'key', 'invalid', /private key is invalid/i], + ['guard wrapper', 'wrapper', 'tampered\n', /managed template/i], + ]; + for (const [label, target, contents, expected] of cases) { + await t.test(label, async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const healthy = await runPlugin(fixture, 'status', null); + assert.deepEqual(JSON.parse(healthy.stdout), { + installed: true, + agentName: 'cline', + displayName: 'Cline', + hookConfigured: true, + hookScriptExists: true, + configPath: fixture.hooksDir, + }); + const targets = { + guard: fixture.guardScriptPath, + config: path.join(fixture.agentDir, 'config.json'), + key: path.join(fixture.agentDir, 'private.key'), + wrapper: fixture.guardWrapperPath, + }; + const filePath = targets[target]; + const next = target === 'wrapper' + ? `${await readFile(filePath, 'utf-8')}${contents}` + : contents; + await writeFile(filePath, next); + const status = await runPlugin(fixture, 'status', null); + assert.match(`${status.stdout}\n${status.stderr}`, expected); + } finally { + await fixture.close(); + } + }); } }); -test('Cline install rejects user filename collisions before every write', async () => { - for (const collision of ['guard', 'audit']) { - const existingGuard = collision === 'guard' ? '// user PreToolUse hook\n' : undefined; - const existingAudit = collision === 'audit' ? '// user PostToolUse hook\n' : undefined; - const fixture = await createFixture({ - autoInstall: false, - existingAudit, - existingGuard, - }); +test('Cline rejects collisions, corrupt metadata, and invalid credentials before writes', async (t) => { + await t.test('user collision', async () => { + const fixture = await createFixture({ existingGuard: '// user hook\n' }); try { - const result = await runPlugin(fixture, 'install', fixture.config); + const result = await fixture.install(); assert.equal(result.code, 1); - assert.match(result.stderr, /already exists and is owned by another integration/i); - if (existingGuard) assert.equal(await readFile(fixture.guardWrapperPath, 'utf-8'), existingGuard); - else await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); - if (existingAudit) assert.equal(await readFile(fixture.auditWrapperPath, 'utf-8'), existingAudit); - else await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); + assert.match(result.stderr, /owned by another integration/i); + assert.equal(await readFile(fixture.guardWrapperPath, 'utf-8'), '// user hook\n'); + await assertMissing(fixture.agentDir); + await assertMissing(fixture.auditWrapperPath); } finally { await fixture.close(); } - } + }); + + await t.test('corrupt ownership metadata', async () => { + const corrupt = '#!/usr/bin/env node\n// @elydora-cline-hook invalid\n'; + const fixture = await createFixture({ existingGuard: corrupt }); + try { + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /parse Elydora Cline hook metadata/i); + assert.equal(await readFile(fixture.guardWrapperPath, 'utf-8'), corrupt); + await assertMissing(fixture.agentDir); + } finally { + await fixture.close(); + } + }); + + await t.test('non-canonical private key', async () => { + const fixture = await createFixture(); + try { + const result = await fixture.install({ privateKey: `${VALID_PRIVATE_KEY}=` }); + assert.equal(result.code, 1); + assert.match(result.stderr, /canonical 32-byte base64url/i); + await assertMissing(fixture.agentDir); + await assertMissing(fixture.guardWrapperPath); + } finally { + await fixture.close(); + } + }); }); -test('Cline install preserves corrupt owned metadata for recovery', async () => { - const corrupt = '#!/usr/bin/env node\n// @elydora-cline-hook invalid\n'; - const fixture = await createFixture({ autoInstall: false, existingGuard: corrupt }); - try { - const result = await runPlugin(fixture, 'install', fixture.config); - assert.equal(result.code, 1); - assert.match(result.stderr, /parse Elydora Cline hook metadata/i); - assert.equal(await readFile(fixture.guardWrapperPath, 'utf-8'), corrupt); - await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); - } finally { - await fixture.close(); - } -}); - -test('Cline install rejects missing runtimes before creating hooks', async () => { - const fixture = await createFixture({ autoInstall: false, createRuntimes: false }); - try { - const result = await runPlugin(fixture, 'install', fixture.config); - assert.equal(result.code, 1); - assert.match(result.stderr, /runtime is missing/i); - await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); - await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); - } finally { - await fixture.close(); - } -}); - -test('Cline uninstall removes exact ownership and preserves other hooks', async () => { +test('Cline uninstall removes exact ownership and preserves adjacent hooks', async () => { const fixture = await createFixture(); const userHook = path.join(fixture.hooksDir, 'PreToolUse.py'); try { - assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + assert.equal((await fixture.install()).code, 0); await writeFile(userHook, '# user hook\n'); - let result = await runPlugin(fixture, 'uninstall', 'agent-10'); - assert.equal(result.code, 0, result.stderr); + assert.equal((await runPlugin(fixture, 'uninstall', 'agent-10')).code, 0); await readFile(fixture.guardWrapperPath); await readFile(fixture.auditWrapperPath); - - result = await runPlugin(fixture, 'uninstall', 'agent-1'); - assert.equal(result.code, 0, result.stderr); - await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); - await assert.rejects(readFile(fixture.auditWrapperPath), { code: 'ENOENT' }); + assert.equal((await runPlugin(fixture, 'uninstall', fixture.agentId)).code, 0); + await assertMissing(fixture.guardWrapperPath); + await assertMissing(fixture.auditWrapperPath); assert.equal(await readFile(userHook, 'utf-8'), '# user hook\n'); } finally { await fixture.close(); } }); + +test('Cline CLI preflight preserves a collision before runtime creation', async () => { + const fixture = await createFixture({ existingAudit: '// user PostToolUse hook\n' }); + const privateKeyFile = path.join(fixture.rootDir, 'private-key'); + try { + await writeFile(privateKeyFile, VALID_PRIVATE_KEY, { mode: 0o600 }); + const result = await runNode([ + '--no-warnings', + cliPath, + 'install', + '--agent', 'cline', + '--org_id', 'org-1', + '--agent_id', fixture.agentId, + '--kid', 'kid-1', + '--private_key_file', privateKeyFile, + '--base_url', fixture.baseUrl, + ], environment(fixture), fixture.projectDir); + assert.equal(result.code, 1); + assert.match(result.stderr, /owned by another integration/i); + assert.equal(await readFile(fixture.auditWrapperPath, 'utf-8'), '// user PostToolUse hook\n'); + await assertMissing(path.join(fixture.homeDir, '.elydora')); + await assertMissing(fixture.guardWrapperPath); + } finally { + await fixture.close(); + } +}); diff --git a/test/cline-transaction.test.mjs b/test/cline-transaction.test.mjs new file mode 100644 index 0000000..af8772b --- /dev/null +++ b/test/cline-transaction.test.mjs @@ -0,0 +1,282 @@ +import assert from 'node:assert/strict'; +import { + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { + contractModuleUrl, + createFixture, + environment, + installConfig, + installationModuleUrl, + ioModuleUrl, + runNode, +} from '../test-support/cline-test-helpers.mjs'; + +const managedFiles = [ + 'guard.js', + 'config.json', + 'private.key', + 'hook.js', +]; + +async function assertMissing(filePath) { + await assert.rejects(lstat(filePath), { code: 'ENOENT' }); +} + +async function snapshotInstallation(fixture) { + const paths = [ + ...managedFiles.map((name) => path.join(fixture.agentDir, name)), + fixture.guardWrapperPath, + fixture.auditWrapperPath, + ]; + return new Map(await Promise.all(paths.map(async (filePath) => [ + filePath, + await readFile(filePath, 'utf-8'), + ]))); +} + +async function assertSnapshot(snapshot) { + for (const [filePath, source] of snapshot) { + assert.equal(await readFile(filePath, 'utf-8'), source, filePath); + } +} + +async function assertNoTransactionFiles(fixture) { + const names = []; + for (const directory of [fixture.agentDir, fixture.hooksDir]) { + try { + names.push(...await readdir(directory)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', ')); +} + +function transactionEnvironment(fixture, overrides = {}) { + return { + ...environment(fixture), + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, overrides)), + }; +} + +const prepareSource = ` + import { resolveHookFiles } from ${JSON.stringify(contractModuleUrl)}; + import { readHookFile } from ${JSON.stringify(ioModuleUrl)}; + import { prepareClineInstallation } from ${JSON.stringify(installationModuleUrl)}; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const paths = resolveHookFiles(); + const guard = await readHookFile(paths.guardPath); + const audit = await readHookFile(paths.auditPath); + const prepared = await prepareClineInstallation(config, guard, audit); +`; + +test('Cline rolls back all six files when a late transaction commit fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = `${prepareSource} + import { commitClineInstallation } from ${JSON.stringify(installationModuleUrl)}; + import { rename } from 'node:fs/promises'; + let calls = 0; + await commitClineInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 5) throw new Error('injected rename failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + transactionEnvironment(fixture, { orgId: 'org-updated', token: 'token-updated' }), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected rename failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cline detects concurrent hook replacement before runtime commit', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '// concurrently replaced\n'; + const source = `${prepareSource} + import { writeFile } from 'node:fs/promises'; + import { commitClineInstallation } from ${JSON.stringify(installationModuleUrl)}; + await writeFile(process.env.ELYDORA_CONCURRENT_PATH, process.env.ELYDORA_CONCURRENT); + await commitClineInstallation(prepared); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + ...transactionEnvironment(fixture, { orgId: 'org-updated' }), + ELYDORA_CONCURRENT_PATH: fixture.auditWrapperPath, + ELYDORA_CONCURRENT: concurrent, + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed during Cline installation/i); + for (const [filePath, sourceBefore] of before) { + if (filePath !== fixture.auditWrapperPath) { + assert.equal(await readFile(filePath, 'utf-8'), sourceBefore, filePath); + } + } + assert.equal(await readFile(fixture.auditWrapperPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cline rejects stale hook snapshots before staging files', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const concurrent = '// stale snapshot replacement\n'; + const source = ` + import { resolveHookFiles } from ${JSON.stringify(contractModuleUrl)}; + import { readHookFile } from ${JSON.stringify(ioModuleUrl)}; + import { prepareClineInstallation } from ${JSON.stringify(installationModuleUrl)}; + import { writeFile } from 'node:fs/promises'; + const config = JSON.parse(process.env.ELYDORA_INSTALL_CONFIG); + const paths = resolveHookFiles(); + const guard = await readHookFile(paths.guardPath); + const audit = await readHookFile(paths.auditPath); + await writeFile(paths.auditPath, process.env.ELYDORA_CONCURRENT); + await prepareClineInstallation(config, guard, audit); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { ...transactionEnvironment(fixture), ELYDORA_CONCURRENT: concurrent }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /changed before installation/i); + for (const [filePath, sourceBefore] of before) { + if (filePath !== fixture.auditWrapperPath) { + assert.equal(await readFile(filePath, 'utf-8'), sourceBefore, filePath); + } + } + assert.equal(await readFile(fixture.auditWrapperPath, 'utf-8'), concurrent); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cline preserves orphaned runtime artifacts without a verifiable identity', async () => { + const fixture = await createFixture(); + try { + await mkdir(fixture.agentDir, { recursive: true }); + await writeFile(fixture.guardScriptPath, 'orphaned guard\n'); + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /identity cannot be verified/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'orphaned guard\n'); + await assertMissing(fixture.guardWrapperPath); + } finally { + await fixture.close(); + } +}); + +test('Cline rejects linked hook directories, runtime roots, and hook files', async (t) => { + for (const kind of ['hooks', 'runtime', 'hook']) { + await t.test(kind, async () => { + const fixture = await createFixture(); + try { + const target = path.join(fixture.rootDir, `${kind}-target`); + await mkdir(target, { recursive: true }); + if (kind === 'hooks') { + await mkdir(path.dirname(fixture.hooksDir), { recursive: true }); + await symlink(target, fixture.hooksDir, 'junction'); + } else if (kind === 'runtime') { + await mkdir(fixture.homeDir, { recursive: true }); + await symlink(target, path.join(fixture.homeDir, '.elydora'), 'junction'); + } else { + assert.equal((await fixture.install()).code, 0); + const targetFile = path.join(target, 'PreToolUse.mjs'); + await writeFile(targetFile, 'external hook\n'); + await rm(fixture.guardWrapperPath); + await symlink(targetFile, fixture.guardWrapperPath, 'file'); + } + const result = await fixture.install(); + assert.equal(result.code, 1); + assert.match(result.stderr, /physical (directory|file)/i); + } catch (error) { + if (error?.code === 'EPERM') { + t.skip(`symbolic links unavailable: ${error.message}`); + return; + } + throw error; + } finally { + await fixture.close(); + } + }); + } +}); + +test('Cline uninstall restores both hooks when the second removal fails', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const before = await snapshotInstallation(fixture); + const source = ` + import { resolveHookFiles } from ${JSON.stringify(contractModuleUrl)}; + import { readHookFile } from ${JSON.stringify(ioModuleUrl)}; + import { + commitClineUninstall, + prepareClineUninstall, + } from ${JSON.stringify(installationModuleUrl)}; + import { rename } from 'node:fs/promises'; + const paths = resolveHookFiles(); + const files = await Promise.all([ + readHookFile(paths.guardPath), + readHookFile(paths.auditPath), + ]); + const prepared = await prepareClineUninstall(files, 'agent-1'); + let calls = 0; + await commitClineUninstall(prepared, async (from, to) => { + calls += 1; + if (calls === 2) throw new Error('injected uninstall failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + environment(fixture), + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /injected uninstall failure/i); + await assertSnapshot(before); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +}); + +test('Cline installation leaves no transaction artifacts', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + await assertNoTransactionFiles(fixture); + } finally { + await fixture.close(); + } +});