From 619fe1659ac22f3f0be17e675c7c0929ad15d20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 09:36:02 -0400 Subject: [PATCH] feat: sync Node Cline hook integration --- AGENTS.md | 2 +- integrations/catalog.json | 4 +- sdks/node/README.md | 3 + sdks/node/src/cli.ts | 2 + sdks/node/src/plugins/cline-contract.ts | 232 ++++++++++++ sdks/node/src/plugins/cline-io.ts | 249 +++++++++++++ sdks/node/src/plugins/cline.ts | 70 ++++ sdks/node/src/plugins/hook-template.ts | 19 +- sdks/node/src/plugins/registry.ts | 1 + sdks/node/test/cline-plugin.test.mjs | 470 ++++++++++++++++++++++++ 10 files changed, 1044 insertions(+), 8 deletions(-) create mode 100644 sdks/node/src/plugins/cline-contract.ts create mode 100644 sdks/node/src/plugins/cline-io.ts create mode 100644 sdks/node/src/plugins/cline.ts create mode 100644 sdks/node/test/cline-plugin.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 423c97e..a6433bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ This repository publishes the open-source server, console, integration catalog, - Write Grok Build integrations to a native user hook file under non-empty `GROK_HOME` or `~/.grok`; keep Claude Code and Cursor compatibility sources read-only. - 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. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. -- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames and return JSON stdout with `cancel: true` for blocking. Treat hook errors, timeouts, and invalid control JSON as fail-open. +- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, and translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index 0727b04..dc909ea 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -85,8 +85,8 @@ "mechanism": "json_stdout_cancel", "failure_mode": "fail_open" }, - "adapters": { "node": false, "python": false, "go": false }, - "delivery_state": "planned", + "adapters": { "node": true, "python": false, "go": false }, + "delivery_state": "partial", "source_url": "https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md" }, { diff --git a/sdks/node/README.md b/sdks/node/README.md index ce6fa88..563bcc6 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -54,6 +54,8 @@ npx elydora install \ Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. +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. + Kimi installation writes the strict hook contract to each detected runtime: Kimi Code's `$KIMI_CODE_HOME/config.toml` (default `~/.kimi-code/config.toml`) and the migrating Python CLI's `~/.kimi/config.toml`. A fresh installation targets current Kimi Code, avoiding cross-runtime migration markers. Both runtimes load the hooks globally; run `/hooks` to inspect them. Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. @@ -78,6 +80,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Augment Code CLI | `augment` | | Claude Code | `claudecode` | | OpenAI Codex | `codex` | +| Cline | `cline` | | Kimi Code | `kimi` | | Grok Build | `grok` | | Cursor | `cursor` | diff --git a/sdks/node/src/cli.ts b/sdks/node/src/cli.ts index 0e0db8f..8b50d91 100644 --- a/sdks/node/src/cli.ts +++ b/sdks/node/src/cli.ts @@ -16,6 +16,7 @@ import { opencodePlugin } from './plugins/opencode.js'; import { copilotPlugin } from './plugins/copilot.js'; import { lettaPlugin } from './plugins/letta.js'; import { codexPlugin } from './plugins/codex.js'; +import { clinePlugin } from './plugins/cline.js'; import { kimiPlugin } from './plugins/kimi.js'; import { grokPlugin } from './plugins/grok.js'; @@ -32,6 +33,7 @@ const PLUGINS: ReadonlyMap = new Map([ ['copilot', copilotPlugin], ['letta', lettaPlugin], ['codex', codexPlugin], + ['cline', clinePlugin], ['kimi', kimiPlugin], ['grok', grokPlugin], ]); diff --git a/sdks/node/src/plugins/cline-contract.ts b/sdks/node/src/plugins/cline-contract.ts new file mode 100644 index 0000000..e044e6b --- /dev/null +++ b/sdks/node/src/plugins/cline-contract.ts @@ -0,0 +1,232 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const AGENT_KEY = 'cline'; +export const GUARD_FILE_NAME = 'PreToolUse.mjs'; +export const AUDIT_FILE_NAME = 'PostToolUse.mjs'; + +const METADATA_MARKER = '// @elydora-cline-hook '; +const METADATA_VERSION = 1; + +export type ClineHookKind = 'guard' | 'audit'; + +export interface ClineHookMetadata { + readonly version: 1; + readonly kind: ClineHookKind; + readonly agentId: string; + readonly runtimePath: string; +} + +export interface ClineHookFile { + readonly exists: boolean; + readonly filePath: string; + readonly source?: string; + readonly metadata?: ClineHookMetadata; +} + +export interface ClineRuntimeContract { + readonly agentId: string; + readonly agentDirectory: string; + readonly guardPath: string; + readonly auditPath: string; +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +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 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; +} + +export function sameAgentId(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +export function resolveHooksDirectory(): string { + const configuredDirectory = process.env.CLINE_DIR?.trim(); + const clineDirectory = configuredDirectory + ? path.resolve(configuredDirectory) + : path.join(os.homedir(), '.cline'); + return path.join(clineDirectory, 'hooks'); +} + +export function resolveHookFiles(): { + readonly hooksDirectory: string; + readonly guardPath: string; + readonly auditPath: string; +} { + const hooksDirectory = resolveHooksDirectory(); + return { + hooksDirectory, + guardPath: path.join(hooksDirectory, GUARD_FILE_NAME), + auditPath: path.join(hooksDirectory, AUDIT_FILE_NAME), + }; +} + +export function buildMetadata( + kind: ClineHookKind, + agentId: string, + runtimePath: string, +): ClineHookMetadata { + if (!agentId) throw new Error('agentId is required'); + if (!path.isAbsolute(runtimePath)) throw new Error(`Cline ${kind} runtime path must be absolute`); + return { version: METADATA_VERSION, kind, agentId, runtimePath }; +} + +function encodeMetadata(metadata: ClineHookMetadata): string { + return Buffer.from(JSON.stringify(metadata), 'utf-8').toString('base64url'); +} + +export function buildWrapper(metadata: ClineHookMetadata): string { + return `#!/usr/bin/env node +${METADATA_MARKER}${encodeMetadata(metadata)} +import { spawn } from 'node:child_process'; + +const hookKind = ${JSON.stringify(metadata.kind)}; +const runtimePath = ${JSON.stringify(metadata.runtimePath)}; + +function runRuntime(input) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [runtimePath], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + const stdout = []; + const stderr = []; + let stdinError; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.stdin.on('error', (error) => { + if (error?.code !== 'EPIPE') stdinError = error; + }); + child.once('error', reject); + child.once('close', (code, signal) => { + if (stdinError) { + reject(stdinError); + return; + } + resolve({ + code, + signal, + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: Buffer.concat(stderr).toString('utf-8'), + }); + }); + child.stdin.end(input); + }); +} + +async function main() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const result = await runRuntime(Buffer.concat(chunks)); + if (result.stdout) process.stderr.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + + 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'); + return; + } + if (result.signal) throw new Error('runtime terminated by signal ' + result.signal); + if (result.code !== 0) throw new Error('runtime exited with code ' + result.code); +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write('Elydora Cline ' + hookKind + ' wrapper failed: ' + message + '\\n'); + process.exitCode = 1; +}); +`; +} + +function validateMetadata(value: unknown): ClineHookMetadata { + if (!isObject(value)) throw new Error('metadata must be an object'); + const keys = Object.keys(value).sort(); + if (keys.join(',') !== 'agentId,kind,runtimePath,version') { + throw new Error('metadata contains an unexpected field set'); + } + if (value.version !== METADATA_VERSION) throw new Error('metadata version must be 1'); + if (value.kind !== 'guard' && value.kind !== 'audit') { + throw new Error('metadata kind must be "guard" or "audit"'); + } + if (typeof value.agentId !== 'string' || value.agentId.length === 0) { + throw new Error('metadata agentId must be a non-empty string'); + } + if (typeof value.runtimePath !== 'string' || !path.isAbsolute(value.runtimePath)) { + throw new Error('metadata runtimePath must be an absolute path'); + } + return value as unknown as ClineHookMetadata; +} + +export function parseMetadata(filePath: string, source: string): ClineHookMetadata | undefined { + const markerLine = source.split(/\r?\n/, 3)[1]; + if (!markerLine?.startsWith(METADATA_MARKER)) return undefined; + try { + const encoded = markerLine.slice(METADATA_MARKER.length); + const decoded = Buffer.from(encoded, 'base64url').toString('utf-8'); + return validateMetadata(JSON.parse(decoded)); + } catch (error) { + throw new Error(`Failed to parse Elydora Cline hook metadata at ${filePath}: ${errorMessage(error)}`, { + cause: asError(error), + }); + } +} + +export function assertWrapperIntegrity(file: ClineHookFile): void { + if (!file.metadata || file.source === undefined) return; + if (file.source !== buildWrapper(file.metadata)) { + throw new Error(`Elydora Cline hook at ${file.filePath} does not match the managed template`); + } +} + +function validateAgentSegment(agentId: string): void { + if (agentId === '.' || agentId === '..' || path.basename(agentId) !== agentId) { + throw new Error('Elydora Cline hook metadata contains an invalid agentId'); + } +} + +export function runtimeContract( + guardFile: ClineHookFile, + auditFile: ClineHookFile, +): ClineRuntimeContract | undefined { + if (!guardFile.metadata || !auditFile.metadata) return undefined; + assertWrapperIntegrity(guardFile); + assertWrapperIntegrity(auditFile); + const guard = guardFile.metadata; + const audit = auditFile.metadata; + if (guard.kind !== 'guard' || audit.kind !== 'audit') { + throw new Error('Elydora Cline hook files contain mismatched event metadata'); + } + if (!sameAgentId(guard.agentId, audit.agentId)) { + throw new Error('Elydora Cline hook files reference different agents'); + } + 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'); + if (!samePath(guard.runtimePath, expectedGuard) || !samePath(audit.runtimePath, expectedAudit)) { + throw new Error('Elydora Cline hook metadata references an unexpected runtime path'); + } + return { + agentId: guard.agentId, + agentDirectory, + guardPath: expectedGuard, + auditPath: expectedAudit, + }; +} diff --git a/sdks/node/src/plugins/cline-io.ts b/sdks/node/src/plugins/cline-io.ts new file mode 100644 index 0000000..2465adf --- /dev/null +++ b/sdks/node/src/plugins/cline-io.ts @@ -0,0 +1,249 @@ +import { randomUUID } from 'node:crypto'; +import fsp from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import path from 'node:path'; +import { + AGENT_KEY, + type ClineHookFile, + type ClineRuntimeContract, + parseMetadata, + sameAgentId, +} from './cline-contract.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)); +} + +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), + }); + } + return { + exists: true, + filePath, + source, + metadata: parseMetadata(filePath, source), + }; +} + +export function requireAvailableHookFile(file: ClineHookFile): void { + if (file.exists && !file.metadata) { + throw new Error(`Cline hook at ${file.filePath} already exists and is owned by another integration`); + } +} + +async function removeTemporary(tempPath: string): Promise { + try { + await fsp.unlink(tempPath); + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) throw error; + } +} + +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)); + } + } + 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; +} + +export async function runtimeFilesExist(contract: ClineRuntimeContract): Promise { + 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'), + ]); + return existence.every(Boolean); +} diff --git a/sdks/node/src/plugins/cline.ts b/sdks/node/src/plugins/cline.ts new file mode 100644 index 0000000..80865a3 --- /dev/null +++ b/sdks/node/src/plugins/cline.ts @@ -0,0 +1,70 @@ +import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js'; +import { + AGENT_KEY, + buildMetadata, + buildWrapper, + resolveHookFiles, + runtimeContract, +} from './cline-contract.js'; +import { + readHookFile, + removeOwnedHooks, + requireAvailableHookFile, + requireRuntime, + runtimeFilesExist, + writeHookPair, +} 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'); + + 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 }, + ); + 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); + }, + + async status(): Promise { + const paths = resolveHookFiles(); + const guardState = await readHookFile(paths.guardPath); + const auditState = await readHookFile(paths.auditPath); + const contract = runtimeContract(guardState, auditState); + const hookConfigured = contract !== undefined; + const hookScriptExists = contract ? await runtimeFilesExist(contract) : false; + return { + installed: hookConfigured && hookScriptExists, + agentName: AGENT_KEY, + displayName: entry.name, + hookConfigured, + hookScriptExists, + configPath: paths.hooksDirectory, + }; + }, +}; diff --git a/sdks/node/src/plugins/hook-template.ts b/sdks/node/src/plugins/hook-template.ts index 880d0ce..475c7b7 100644 --- a/sdks/node/src/plugins/hook-template.ts +++ b/sdks/node/src/plugins/hook-template.ts @@ -180,11 +180,20 @@ async function main() { return; } - // Extract tool_name and tool_input from hook data - // Different agents may pass data in different shapes - const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown'; - const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {}; - const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown'; + // Extract provider-native tool fields without reshaping the hook input. + const clineToolEvent = + hookData.tool_result && typeof hookData.tool_result === 'object' + ? hookData.tool_result + : hookData.tool_call && typeof hookData.tool_call === 'object' + ? hookData.tool_call + : {}; + const toolName = + hookData.tool_name || hookData.toolName || hookData.name || clineToolEvent.name || 'unknown'; + const toolInput = + hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || + clineToolEvent.input || {}; + const sessionId = + hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown'; // Read agent config and private key let config; diff --git a/sdks/node/src/plugins/registry.ts b/sdks/node/src/plugins/registry.ts index 5697f6d..f55d7d0 100644 --- a/sdks/node/src/plugins/registry.ts +++ b/sdks/node/src/plugins/registry.ts @@ -15,6 +15,7 @@ export const SUPPORTED_AGENTS: ReadonlyMap = new Map ['copilot', { name: 'Copilot CLI', configDir: '.github/hooks', configFile: 'hooks.json' }], ['letta', { name: 'Letta Code', configDir: '~/.letta', configFile: 'settings.json' }], ['codex', { name: 'OpenAI Codex', configDir: '~/.codex', configFile: 'hooks.json' }], + ['cline', { name: 'Cline', configDir: '~/.cline/hooks', configFile: 'PreToolUse.mjs' }], ['kimi', { name: 'Kimi Code', configDir: '~/.kimi-code', configFile: 'config.toml' }], ['grok', { name: 'Grok Build', configDir: '~/.grok/hooks', configFile: 'elydora-audit.json' }], ]); diff --git a/sdks/node/test/cline-plugin.test.mjs b/sdks/node/test/cline-plugin.test.mjs new file mode 100644 index 0000000..cb2d09e --- /dev/null +++ b/sdks/node/test/cline-plugin.test.mjs @@ -0,0 +1,470 @@ +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 path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +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 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)); +} + +test('Cline is registered in the SDK and CLI', async () => { + const { SUPPORTED_AGENTS } = await import(registryModuleUrl); + assert.deepEqual(SUPPORTED_AGENTS.get('cline'), { + name: 'Cline', + configDir: '~/.cline/hooks', + configFile: 'PreToolUse.mjs', + }); + const fixture = await createFixture({ autoInstall: false }); + try { + const result = await runNode(['--no-warnings', cliPath, 'status'], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + CLINE_DIR: fixture.clineDir, + }, '', fixture.workspaceDir); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Cline \(cline\)/); + } finally { + await fixture.close(); + } +}); + +test('Cline install writes only the native global hooks and is idempotent', 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 second = await runPlugin(fixture, 'install', fixture.config); + 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' }, + ); + } 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'); + try { + const result = await runPlugin(fixture, 'install', fixture.config, null); + assert.equal(result.code, 0, result.stderr); + await readFile(path.join(defaultHooks, 'PreToolUse.mjs')); + await readFile(path.join(defaultHooks, 'PostToolUse.mjs')); + await assert.rejects(readFile(fixture.guardWrapperPath), { code: 'ENOENT' }); + } 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 }); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + const prePayload = JSON.stringify({ + clineVersion: '3.0.46', + hookName: 'tool_call', + taskId: 'task-1', + tool_call: { id: 'call-1', name: 'read_file', input: { path: 'README.md' } }, + }); + const guard = await runNode( + [fixture.guardWrapperPath], + { HOME: fixture.homeDir, USERPROFILE: fixture.homeDir }, + prePayload, + fixture.workspaceDir, + ); + assert.equal(guard.code, 0, guard.stderr); + assert.match(guard.stderr, /Agent is frozen by Elydora/); + assert.deepEqual(parseControl(guard.stdout), { + cancel: true, + errorMessage: 'Agent is frozen by Elydora.', + }); + + const postPayload = JSON.stringify({ + clineVersion: '3.0.46', + hookName: 'tool_result', + taskId: 'task-1', + tool_result: { + id: 'call-1', + name: 'read_file', + input: { path: 'README.md' }, + 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', + }, + }); + const result = await runNode([fixture.auditWrapperPath], { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + }, payload, fixture.workspaceDir); + 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.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 fixture.close(); + } +}); + +test('Cline wrappers keep pass decisions quiet and surface runtime failures', async () => { + const passing = await createFixture({ guardSource: 'process.stdin.resume();\n' }); + try { + assert.equal(passing.installResult.code, 0, passing.installResult.stderr); + const result = await runNode([passing.guardWrapperPath], {}, '{}', passing.workspaceDir); + 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); + 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 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, + }); + try { + const result = await runPlugin(fixture, 'install', fixture.config); + 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' }); + } 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 () => { + const fixture = await createFixture(); + const userHook = path.join(fixture.hooksDir, 'PreToolUse.py'); + try { + assert.equal(fixture.installResult.code, 0, fixture.installResult.stderr); + await writeFile(userHook, '# user hook\n'); + let result = await runPlugin(fixture, 'uninstall', 'agent-10'); + assert.equal(result.code, 0, result.stderr); + 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 readFile(userHook, 'utf-8'), '# user hook\n'); + } finally { + await fixture.close(); + } +});