feat: add native Cline hook integration
This commit is contained in:
1 parent
9d03a68477
commit
2eff1ae119
10 files changed
+1316
-5
No files matched your search
@@ -26,6 +26,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
|
||||
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it.
|
||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ 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 wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control.
|
||||
|
||||
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
||||
|
||||
Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present.
|
||||
@@ -100,6 +102,7 @@ Auggie installation writes user-level hooks to `~/.augment/settings.json` and cr
|
||||
| Augment Code CLI | `augment` |
|
||||
| Claude Code | `claudecode` |
|
||||
| OpenAI Codex | `codex` |
|
||||
| Cline | `cline` |
|
||||
| Copilot CLI | `copilot` |
|
||||
| Cursor | `cursor` |
|
||||
| Gemini CLI | `gemini` |
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package plugins
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ClinePlugin manages Cline's native global file hooks.
|
||||
type ClinePlugin struct{}
|
||||
|
||||
func (p *ClinePlugin) Install(config InstallConfig) error {
|
||||
if config.AgentID == "" {
|
||||
return fmt.Errorf("agent ID is required")
|
||||
}
|
||||
paths, err := resolveClineHookFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guardState, err := readClineHookFile(paths.guardPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditState, err := readClineHookFile(paths.auditPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAvailableClineHook(guardState); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAvailableClineHook(auditState); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.GuardScriptPath == "" {
|
||||
return fmt.Errorf("guard script path is required")
|
||||
}
|
||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !guardExists {
|
||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
||||
}
|
||||
hookPath, err := hookScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
hookPath = config.HookScript
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build Cline guard metadata: %w", err)
|
||||
}
|
||||
auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build Cline audit metadata: %w", err)
|
||||
}
|
||||
guardSource, err := buildClineWrapper(guardMetadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build Cline guard wrapper: %w", err)
|
||||
}
|
||||
auditSource, err := buildClineWrapper(auditMetadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build Cline audit wrapper: %w", err)
|
||||
}
|
||||
if _, err := clineContractForFiles(
|
||||
clineHookFile{
|
||||
exists: true, filePath: paths.guardPath,
|
||||
source: guardSource, metadata: &guardMetadata,
|
||||
},
|
||||
clineHookFile{
|
||||
exists: true, filePath: paths.auditPath,
|
||||
source: auditSource, metadata: &auditMetadata,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeConfig := config
|
||||
runtimeConfig.AgentName = clineAgentKey
|
||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
if err := writeClineHookPair(
|
||||
clinePendingWrite{state: guardState, source: guardSource},
|
||||
clinePendingWrite{state: auditState, source: auditSource},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Cline: user-level PreToolUse and PostToolUse hooks installed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ClinePlugin) Uninstall(agentID string) error {
|
||||
paths, err := resolveClineHookFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guardState, err := readClineHookFile(paths.guardPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditState, err := readClineHookFile(paths.auditPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID)
|
||||
}
|
||||
|
||||
func (p *ClinePlugin) Status() (PluginStatus, error) {
|
||||
paths, err := resolveClineHookFiles()
|
||||
status := PluginStatus{
|
||||
AgentName: clineAgentKey,
|
||||
DisplayName: "Cline",
|
||||
ConfigPath: paths.hooksDirectory,
|
||||
}
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
guardState, err := readClineHookFile(paths.guardPath)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
auditState, err := readClineHookFile(paths.auditPath)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
contract, err := clineContractForFiles(guardState, auditState)
|
||||
if err != nil || contract == nil {
|
||||
return status, err
|
||||
}
|
||||
status.HookConfigured = true
|
||||
status.HookScriptExists, err = clineRuntimeFilesExist(contract)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status.Installed = status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
clineAgentKey = "cline"
|
||||
clineGuardFileName = "PreToolUse.mjs"
|
||||
clineAuditFileName = "PostToolUse.mjs"
|
||||
clineGuardScript = "guard.js"
|
||||
clineAuditScript = "hook.js"
|
||||
clineMetadataMarker = "// @elydora-cline-hook "
|
||||
clineMetadataVersion = 1
|
||||
)
|
||||
|
||||
type clineHookMetadata struct {
|
||||
Version int `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
AgentID string `json:"agentId"`
|
||||
RuntimePath string `json:"runtimePath"`
|
||||
}
|
||||
|
||||
type clineHookFile struct {
|
||||
exists bool
|
||||
filePath string
|
||||
source string
|
||||
metadata *clineHookMetadata
|
||||
}
|
||||
|
||||
type clineHookPaths struct {
|
||||
hooksDirectory string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
type clineRuntimeContract struct {
|
||||
agentID string
|
||||
agentDirectory string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
func clineHomeDirectory() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
func clineElydoraRoot() (string, error) {
|
||||
home, err := clineHomeDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".elydora"), nil
|
||||
}
|
||||
|
||||
func resolveClineHookFiles() (clineHookPaths, error) {
|
||||
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
|
||||
if clineDirectory == "" {
|
||||
home, err := clineHomeDirectory()
|
||||
if err != nil {
|
||||
return clineHookPaths{}, err
|
||||
}
|
||||
clineDirectory = filepath.Join(home, ".cline")
|
||||
} else {
|
||||
absolute, err := filepath.Abs(clineDirectory)
|
||||
if err != nil {
|
||||
return clineHookPaths{}, fmt.Errorf("resolve CLINE_DIR path: %w", err)
|
||||
}
|
||||
clineDirectory = absolute
|
||||
}
|
||||
hooksDirectory := filepath.Join(clineDirectory, "hooks")
|
||||
return clineHookPaths{
|
||||
hooksDirectory: hooksDirectory,
|
||||
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
|
||||
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sameClineAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func normalizeClinePath(value string) (string, error) {
|
||||
absolute, err := filepath.Abs(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalized := filepath.Clean(absolute)
|
||||
if runtime.GOOS == "windows" {
|
||||
normalized = strings.ToLower(normalized)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func sameClinePath(left, right string) (bool, error) {
|
||||
normalizedLeft, err := normalizeClinePath(left)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
normalizedRight, err := normalizeClinePath(right)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return normalizedLeft == normalizedRight, nil
|
||||
}
|
||||
|
||||
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
|
||||
metadata := clineHookMetadata{
|
||||
Version: clineMetadataVersion,
|
||||
Kind: kind,
|
||||
AgentID: agentID,
|
||||
RuntimePath: runtimePath,
|
||||
}
|
||||
if err := validateClineMetadata(metadata); err != nil {
|
||||
return clineHookMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func validateClineMetadata(metadata clineHookMetadata) error {
|
||||
if metadata.Version != clineMetadataVersion {
|
||||
return fmt.Errorf("metadata version must be 1")
|
||||
}
|
||||
if metadata.Kind != "guard" && metadata.Kind != "audit" {
|
||||
return fmt.Errorf(`metadata kind must be "guard" or "audit"`)
|
||||
}
|
||||
if metadata.AgentID == "" {
|
||||
return fmt.Errorf("metadata agentId must be a non-empty string")
|
||||
}
|
||||
if !filepath.IsAbs(metadata.RuntimePath) {
|
||||
return fmt.Errorf("metadata runtimePath must be an absolute path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeClineMetadata(metadata clineHookMetadata) (string, error) {
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode Cline hook metadata: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(encoded), nil
|
||||
}
|
||||
|
||||
func clineJSONString(value string) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func buildClineWrapper(metadata clineHookMetadata) (string, error) {
|
||||
if err := validateClineMetadata(metadata); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encodedMetadata, err := encodeClineMetadata(metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hookKind, err := clineJSONString(metadata.Kind)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode Cline hook kind: %w", err)
|
||||
}
|
||||
runtimePath, err := clineJSONString(metadata.RuntimePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode Cline runtime path: %w", err)
|
||||
}
|
||||
const template = `#!/usr/bin/env node
|
||||
__METADATA_LINE__
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const hookKind = __HOOK_KIND__;
|
||||
const runtimePath = __RUNTIME_PATH__;
|
||||
|
||||
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;
|
||||
});
|
||||
`
|
||||
return strings.NewReplacer(
|
||||
"__METADATA_LINE__", clineMetadataMarker+encodedMetadata,
|
||||
"__HOOK_KIND__", hookKind,
|
||||
"__RUNTIME_PATH__", runtimePath,
|
||||
).Replace(template), nil
|
||||
}
|
||||
|
||||
func decodeClineMetadata(encoded string) (clineHookMetadata, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
|
||||
}
|
||||
expected := []string{"version", "kind", "agentId", "runtimePath"}
|
||||
if len(fields) != len(expected) {
|
||||
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
|
||||
}
|
||||
for _, field := range expected {
|
||||
if _, exists := fields[field]; !exists {
|
||||
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
|
||||
}
|
||||
}
|
||||
var metadata clineHookMetadata
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
|
||||
}
|
||||
if err := validateClineMetadata(metadata); err != nil {
|
||||
return clineHookMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func parseClineMetadata(filePath, source string) (*clineHookMetadata, error) {
|
||||
lines := strings.SplitN(source, "\n", 3)
|
||||
if len(lines) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
markerLine := strings.TrimSuffix(lines[1], "\r")
|
||||
if !strings.HasPrefix(markerLine, clineMetadataMarker) {
|
||||
return nil, nil
|
||||
}
|
||||
metadata, err := decodeClineMetadata(strings.TrimPrefix(markerLine, clineMetadataMarker))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Elydora Cline hook metadata at %s: %w", filePath, err)
|
||||
}
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
func assertClineWrapperIntegrity(file clineHookFile) error {
|
||||
if file.metadata == nil {
|
||||
return nil
|
||||
}
|
||||
expected, err := buildClineWrapper(*file.metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.source != expected {
|
||||
return fmt.Errorf("Elydora Cline hook at %s does not match the managed template", file.filePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClineAgentSegment(agentID string) error {
|
||||
if agentID == "." || agentID == ".." || filepath.Base(agentID) != agentID {
|
||||
return fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clineContractForFiles(
|
||||
guardFile, auditFile clineHookFile,
|
||||
) (*clineRuntimeContract, error) {
|
||||
if guardFile.metadata == nil || auditFile.metadata == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := assertClineWrapperIntegrity(guardFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertClineWrapperIntegrity(auditFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guard := guardFile.metadata
|
||||
audit := auditFile.metadata
|
||||
if guard.Kind != "guard" || audit.Kind != "audit" {
|
||||
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
|
||||
}
|
||||
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
|
||||
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
||||
}
|
||||
if err := validateClineAgentSegment(guard.AgentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := clineElydoraRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory := filepath.Join(runtimeRoot, guard.AgentID)
|
||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
|
||||
}
|
||||
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
|
||||
}
|
||||
if !guardMatches || !auditMatches {
|
||||
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
||||
}
|
||||
return &clineRuntimeContract{
|
||||
agentID: guard.AgentID,
|
||||
agentDirectory: agentDirectory,
|
||||
guardPath: expectedGuard,
|
||||
auditPath: expectedAudit,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type clinePendingWrite struct {
|
||||
state clineHookFile
|
||||
source string
|
||||
}
|
||||
|
||||
type clineStagedFile struct {
|
||||
state clineHookFile
|
||||
temporaryPath string
|
||||
}
|
||||
|
||||
type clineRenameFunc func(source, destination string) error
|
||||
|
||||
func readClineHookFile(filePath string) (clineHookFile, error) {
|
||||
// #nosec G304 -- filePath is resolved from Cline's native hook directory.
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return clineHookFile{filePath: filePath}, nil
|
||||
}
|
||||
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err)
|
||||
}
|
||||
source := string(raw)
|
||||
metadata, err := parseClineMetadata(filePath, source)
|
||||
if err != nil {
|
||||
return clineHookFile{}, err
|
||||
}
|
||||
return clineHookFile{
|
||||
exists: true,
|
||||
filePath: filePath,
|
||||
source: source,
|
||||
metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requireAvailableClineHook(file clineHookFile) error {
|
||||
if file.exists && file.metadata == nil {
|
||||
return fmt.Errorf(
|
||||
"Cline hook at %s already exists and is owned by another integration",
|
||||
file.filePath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeClineTemporary(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func failClineStage(temporary *os.File, path string, cause error) error {
|
||||
closeErr := temporary.Close()
|
||||
removeErr := removeClineTemporary(path)
|
||||
return errors.Join(cause, closeErr, removeErr)
|
||||
}
|
||||
|
||||
func stageClineHook(write clinePendingWrite) (clineStagedFile, error) {
|
||||
directory := filepath.Dir(write.state.filePath)
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp")
|
||||
if err != nil {
|
||||
return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
if err := temporary.Chmod(0700); err != nil {
|
||||
return clineStagedFile{}, failClineStage(
|
||||
temporary,
|
||||
temporaryPath,
|
||||
fmt.Errorf("set permissions for %s: %w", temporaryPath, err),
|
||||
)
|
||||
}
|
||||
written, err := temporary.WriteString(write.source)
|
||||
if err != nil {
|
||||
return clineStagedFile{}, failClineStage(
|
||||
temporary,
|
||||
temporaryPath,
|
||||
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err),
|
||||
)
|
||||
}
|
||||
if written != len(write.source) {
|
||||
return clineStagedFile{}, failClineStage(
|
||||
temporary,
|
||||
temporaryPath,
|
||||
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite),
|
||||
)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return clineStagedFile{}, failClineStage(
|
||||
temporary,
|
||||
temporaryPath,
|
||||
fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err),
|
||||
)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return clineStagedFile{}, errors.Join(
|
||||
fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err),
|
||||
removeClineTemporary(temporaryPath),
|
||||
)
|
||||
}
|
||||
return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil
|
||||
}
|
||||
|
||||
func rollbackClineHook(state clineHookFile) error {
|
||||
if state.exists {
|
||||
return writeHookFileAtomic(state.filePath, []byte(state.source), 0700)
|
||||
}
|
||||
return removeHookFile(state.filePath, "Cline hook rollback")
|
||||
}
|
||||
|
||||
func writeClineHookPair(guard, audit clinePendingWrite) error {
|
||||
return writeClineHookPairWithRename(guard, audit, os.Rename)
|
||||
}
|
||||
|
||||
func writeClineHookPairWithRename(
|
||||
guard, audit clinePendingWrite,
|
||||
rename clineRenameFunc,
|
||||
) error {
|
||||
writes := []clinePendingWrite{guard, audit}
|
||||
staged := make([]clineStagedFile, 0, len(writes))
|
||||
for _, write := range writes {
|
||||
file, err := stageClineHook(write)
|
||||
if err != nil {
|
||||
failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)}
|
||||
for _, item := range staged {
|
||||
if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil {
|
||||
failures = append(failures, cleanupErr)
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
staged = append(staged, file)
|
||||
}
|
||||
|
||||
committed := 0
|
||||
for index, item := range staged {
|
||||
if err := rename(item.temporaryPath, item.state.filePath); err != nil {
|
||||
failures := []error{
|
||||
fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err),
|
||||
}
|
||||
for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- {
|
||||
if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil {
|
||||
failures = append(failures, fmt.Errorf(
|
||||
"restore Cline hook at %s: %w",
|
||||
staged[rollbackIndex].state.filePath,
|
||||
rollbackErr,
|
||||
))
|
||||
}
|
||||
}
|
||||
for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ {
|
||||
if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil {
|
||||
failures = append(failures, cleanupErr)
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
committed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeOwnedClineHooks(files []clineHookFile, agentID string) error {
|
||||
for _, file := range files {
|
||||
if file.metadata == nil {
|
||||
continue
|
||||
}
|
||||
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
|
||||
continue
|
||||
}
|
||||
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
||||
configPath := filepath.Join(contract.agentDirectory, "config.json")
|
||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
||||
if err != nil || !exists {
|
||||
return false, err
|
||||
}
|
||||
agentID, ok := config["agent_id"].(string)
|
||||
if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) {
|
||||
return false, nil
|
||||
}
|
||||
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return guardExists && auditExists, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||
t.Fatalf("installed status = %#v, %v", status, err)
|
||||
}
|
||||
if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir {
|
||||
t.Fatalf("status metadata = %#v", status)
|
||||
}
|
||||
if err := os.Remove(fixture.guardPath); err != nil {
|
||||
t.Fatalf("remove guard runtime: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||
t.Fatalf("degraded status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil {
|
||||
t.Fatalf("restore guard runtime: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.auditWrapper); err != nil {
|
||||
t.Fatalf("remove audit wrapper: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("partial status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
guard := readClineTestFile(t, fixture.guardWrapper)
|
||||
if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil {
|
||||
t.Fatalf("tamper guard wrapper: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") {
|
||||
t.Fatalf("tampered status error = %v", err)
|
||||
}
|
||||
installClineFixture(t, fixture)
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("runtime status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) {
|
||||
for _, collision := range []string{"guard", "audit"} {
|
||||
t.Run(collision, func(t *testing.T) {
|
||||
options := clineFixtureOptions{}
|
||||
if collision == "guard" {
|
||||
options.ExistingGuard = clineString("// user PreToolUse hook\n")
|
||||
} else {
|
||||
options.ExistingAudit = clineString("// user PostToolUse hook\n")
|
||||
}
|
||||
fixture := prepareClineFixture(t, options)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "owned by another integration") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
|
||||
t.Fatalf("user guard wrapper changed")
|
||||
}
|
||||
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
|
||||
t.Fatalf("user audit wrapper changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) {
|
||||
corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n"
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.guardWrapper) != corrupt {
|
||||
t.Fatalf("corrupt hook changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
requireMissingClineTestFile(t, fixture.hookPath)
|
||||
}
|
||||
|
||||
func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
fixture.hookPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
|
||||
if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil {
|
||||
t.Fatalf("write user hook: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
||||
t.Fatalf("uninstall other agent: %v", err)
|
||||
}
|
||||
readClineTestFile(t, fixture.guardWrapper)
|
||||
readClineTestFile(t, fixture.auditWrapper)
|
||||
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Cline hooks: %v", err)
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
if readClineTestFile(t, userHook) != "# user hook\n" {
|
||||
t.Fatalf("user hook changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
||||
guardState, err := readClineHookFile(fixture.guardWrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("read guard state: %v", err)
|
||||
}
|
||||
auditState, err := readClineHookFile(fixture.auditWrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("read audit state: %v", err)
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement guard metadata: %v", err)
|
||||
}
|
||||
auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement audit metadata: %v", err)
|
||||
}
|
||||
replacementGuard, err := buildClineWrapper(guardMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement guard: %v", err)
|
||||
}
|
||||
replacementAudit, err := buildClineWrapper(auditMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement audit: %v", err)
|
||||
}
|
||||
failureInjected := false
|
||||
rename := func(source, destination string) error {
|
||||
if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) {
|
||||
failureInjected = true
|
||||
return errors.New("simulated audit commit failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err = writeClineHookPairWithRename(
|
||||
clinePendingWrite{state: guardState, source: replacementGuard},
|
||||
clinePendingWrite{state: auditState, source: replacementAudit},
|
||||
rename,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") {
|
||||
t.Fatalf("write pair error = %v", err)
|
||||
}
|
||||
if !failureInjected {
|
||||
t.Fatalf("audit commit failure was not injected")
|
||||
}
|
||||
if readClineTestFile(t, fixture.guardWrapper) != originalGuard ||
|
||||
readClineTestFile(t, fixture.auditWrapper) != originalAudit {
|
||||
t.Fatalf("hook pair was not restored")
|
||||
}
|
||||
entries, err := os.ReadDir(fixture.hooksDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read hook directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("temporary hook remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameClineTestPath(left, right string) bool {
|
||||
leftPath, leftErr := filepath.Abs(left)
|
||||
rightPath, rightErr := filepath.Abs(right)
|
||||
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) {
|
||||
entry := SupportedAgents["cline"]
|
||||
if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" {
|
||||
t.Fatalf("Cline registry entry = %#v", entry)
|
||||
}
|
||||
if _, ok := NewPlugin("cline").(*ClinePlugin); !ok {
|
||||
t.Fatalf("Cline plugin is not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
||||
|
||||
installClineFixture(t, fixture)
|
||||
|
||||
if readClineTestFile(t, fixture.guardWrapper) != originalGuard {
|
||||
t.Fatalf("guard wrapper changed during idempotent install")
|
||||
}
|
||||
if readClineTestFile(t, fixture.auditWrapper) != originalAudit {
|
||||
t.Fatalf("audit wrapper changed during idempotent install")
|
||||
}
|
||||
if !strings.HasPrefix(originalGuard, "#!/usr/bin/env node\n// @elydora-cline-hook ") {
|
||||
t.Fatalf("guard wrapper has unexpected header: %q", originalGuard)
|
||||
}
|
||||
entries, err := os.ReadDir(fixture.hooksDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read Cline hook directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("temporary hook file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs"))
|
||||
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
|
||||
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs"))
|
||||
}
|
||||
|
||||
func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
t.Setenv("CLINE_DIR", "")
|
||||
installClineFixture(t, fixture)
|
||||
|
||||
defaultHooks := filepath.Join(fixture.homeDir, ".cline", "hooks")
|
||||
readClineTestFile(t, filepath.Join(defaultHooks, "PreToolUse.mjs"))
|
||||
readClineTestFile(t, filepath.Join(defaultHooks, "PostToolUse.mjs"))
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
}
|
||||
|
||||
func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||
captureJSON, err := json.Marshal(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal capture path: %v", err)
|
||||
}
|
||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", JSON.stringify({" +
|
||||
"cwd: process.cwd(), input: Buffer.concat(chunks).toString('utf-8')})));\n"
|
||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
||||
t.Fatalf("write capture hook: %v", err)
|
||||
}
|
||||
|
||||
prePayload := []byte(`{"clineVersion":"3.0.46","hookName":"tool_call","taskId":"task-1","tool_call":{"id":"call-1","name":"read_file","input":{"path":"README.md"}}}`)
|
||||
guard := runClineWrapper(t, fixture, fixture.guardWrapper, prePayload)
|
||||
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Agent is frozen by Elydora") {
|
||||
t.Fatalf("guard result = %#v", guard)
|
||||
}
|
||||
control := decodeClineControl(t, guard.stdout)
|
||||
if control["cancel"] != true || control["errorMessage"] != "Agent is frozen by Elydora." {
|
||||
t.Fatalf("guard control = %#v", control)
|
||||
}
|
||||
|
||||
postPayload := []byte("{\n \"clineVersion\": \"3.0.46\",\n \"hookName\": \"tool_result\",\n \"taskId\": \"task-1\",\n \"tool_result\": {\"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n}")
|
||||
audit := runClineWrapper(t, fixture, fixture.auditWrapper, postPayload)
|
||||
if audit.exitCode != 0 || audit.stdout != "" {
|
||||
t.Fatalf("audit result = %#v", audit)
|
||||
}
|
||||
var captured map[string]any
|
||||
if err := json.Unmarshal([]byte(readClineTestFile(t, capturePath)), &captured); err != nil {
|
||||
t.Fatalf("decode captured payload: %v", err)
|
||||
}
|
||||
if captured["cwd"] != fixture.workspaceDir || captured["input"] != string(postPayload) {
|
||||
t.Fatalf("captured payload = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
||||
operations := make(chan map[string]any, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
defer request.Body.Close()
|
||||
var operation map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
|
||||
http.Error(response, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
operations <- operation
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
_, _ = response.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
fixture.config.BaseURL = server.URL
|
||||
installClineFixture(t, fixture)
|
||||
payload := []byte(`{"hookName":"tool_result","taskId":"task-1","tool_result":{"name":"read_file","input":{"path":"README.md"},"output":"ok"}}`)
|
||||
result := runClineWrapper(t, fixture, fixture.auditWrapper, payload)
|
||||
if result.exitCode != 0 {
|
||||
t.Fatalf("audit wrapper result = %#v", result)
|
||||
}
|
||||
|
||||
var operation map[string]any
|
||||
select {
|
||||
case operation = <-operations:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for Elydora operation")
|
||||
}
|
||||
wantPayload := map[string]any{
|
||||
"tool_name": "read_file",
|
||||
"tool_input": map[string]any{"path": "README.md"},
|
||||
"session_id": "task-1",
|
||||
}
|
||||
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) {
|
||||
t.Fatalf("operation payload = %#v", operation["payload"])
|
||||
}
|
||||
if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) {
|
||||
t.Fatalf("operation action = %#v", operation["action"])
|
||||
}
|
||||
if !reflect.DeepEqual(requireClineTestObject(t, operation["subject"]), map[string]any{"session_id": "task-1"}) {
|
||||
t.Fatalf("operation subject = %#v", operation["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
|
||||
passing := prepareClineFixture(t, clineFixtureOptions{
|
||||
GuardSource: clineString("process.stdin.resume();\n"),
|
||||
})
|
||||
installClineFixture(t, passing)
|
||||
passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`))
|
||||
if passResult.exitCode != 0 || passResult.stdout != "" {
|
||||
t.Fatalf("passing guard result = %#v", passResult)
|
||||
}
|
||||
|
||||
failing := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, failing)
|
||||
if err := os.WriteFile(
|
||||
failing.hookPath,
|
||||
[]byte("process.stderr.write('audit failed\\n'); process.exit(7);\n"),
|
||||
0700,
|
||||
); err != nil {
|
||||
t.Fatalf("write failing audit runtime: %v", err)
|
||||
}
|
||||
failResult := runClineWrapper(t, failing, failing.auditWrapper, []byte(`{}`))
|
||||
if failResult.exitCode != 1 || !strings.Contains(failResult.stderr, "audit failed") ||
|
||||
!strings.Contains(failResult.stderr, "exited with code 7") {
|
||||
t.Fatalf("failing audit result = %#v", failResult)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const clineTestAgentID = "agent-1"
|
||||
|
||||
type clineFixtureOptions struct {
|
||||
ExistingAudit *string
|
||||
ExistingGuard *string
|
||||
GuardSource *string
|
||||
SkipGuard bool
|
||||
}
|
||||
|
||||
type clineFixture struct {
|
||||
plugin *ClinePlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
workspaceDir string
|
||||
clineDir string
|
||||
hooksDir string
|
||||
agentDir string
|
||||
guardPath string
|
||||
hookPath string
|
||||
guardWrapper string
|
||||
auditWrapper string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
}
|
||||
|
||||
type clineCommandResult struct {
|
||||
exitCode int
|
||||
stdout string
|
||||
stderr string
|
||||
}
|
||||
|
||||
func clineString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixture {
|
||||
t.Helper()
|
||||
rootDir := t.TempDir()
|
||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||
workspaceDir := filepath.Join(rootDir, "workspace")
|
||||
clineDir := filepath.Join(rootDir, "custom-cline-home")
|
||||
hooksDir := filepath.Join(clineDir, "hooks")
|
||||
agentDir := filepath.Join(homeDir, ".elydora", clineTestAgentID)
|
||||
guardPath := filepath.Join(agentDir, "guard.js")
|
||||
hookPath := filepath.Join(agentDir, "hook.js")
|
||||
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
|
||||
auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs")
|
||||
|
||||
for _, directory := range []string{workspaceDir, agentDir} {
|
||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||
t.Fatalf("create fixture directory %s: %v", directory, err)
|
||||
}
|
||||
}
|
||||
if !options.SkipGuard {
|
||||
guardSource := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"
|
||||
if options.GuardSource != nil {
|
||||
guardSource = *options.GuardSource
|
||||
}
|
||||
if err := os.WriteFile(guardPath, []byte(guardSource), 0700); err != nil {
|
||||
t.Fatalf("write guard runtime: %v", err)
|
||||
}
|
||||
}
|
||||
writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard)
|
||||
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
|
||||
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return &clineFixture{
|
||||
plugin: &ClinePlugin{},
|
||||
homeDir: homeDir,
|
||||
workspaceDir: workspaceDir,
|
||||
clineDir: clineDir,
|
||||
hooksDir: hooksDir,
|
||||
agentDir: agentDir,
|
||||
guardPath: guardPath,
|
||||
hookPath: hookPath,
|
||||
guardWrapper: guardWrapper,
|
||||
auditWrapper: auditWrapper,
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
config: InstallConfig{
|
||||
AgentName: "cline",
|
||||
OrgID: "org-1",
|
||||
AgentID: clineTestAgentID,
|
||||
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
|
||||
KID: "kid-1",
|
||||
BaseURL: "https://api.elydora.test",
|
||||
GuardScriptPath: guardPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeOptionalClineTestFile(t *testing.T, path string, contents *string) {
|
||||
t.Helper()
|
||||
if contents == nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("create directory for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(*contents), 0600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func installClineFixture(t *testing.T, fixture *clineFixture) {
|
||||
t.Helper()
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Cline hooks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClineWrapper(
|
||||
t *testing.T,
|
||||
fixture *clineFixture,
|
||||
wrapperPath string,
|
||||
payload []byte,
|
||||
) clineCommandResult {
|
||||
t.Helper()
|
||||
nodePath, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// #nosec G204 -- nodePath is resolved with exec.LookPath and the wrapper path is test-owned.
|
||||
command := exec.CommandContext(ctx, nodePath, wrapperPath)
|
||||
command.Dir = fixture.workspaceDir
|
||||
command.Env = append(
|
||||
os.Environ(),
|
||||
"HOME="+fixture.homeDir,
|
||||
"USERPROFILE="+fixture.homeDir,
|
||||
)
|
||||
command.Stdin = bytes.NewReader(payload)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
runErr := command.Run()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("Cline wrapper timed out: %v", ctx.Err())
|
||||
}
|
||||
exitCode := 0
|
||||
if runErr != nil {
|
||||
var exitError *exec.ExitError
|
||||
if !errors.As(runErr, &exitError) {
|
||||
t.Fatalf("run Cline wrapper: %v", runErr)
|
||||
}
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
return clineCommandResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
|
||||
func readClineTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(contents)
|
||||
}
|
||||
|
||||
func requireMissingClineTestFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be missing, got %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireClineTestObject(t *testing.T, value any) map[string]any {
|
||||
t.Helper()
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("value is not an object: %#v", value)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func decodeClineControl(t *testing.T, stdout string) map[string]any {
|
||||
t.Helper()
|
||||
const marker = "HOOK_CONTROL\t"
|
||||
if len(stdout) < len(marker) || stdout[:len(marker)] != marker {
|
||||
t.Fatalf("Cline control output = %q", stdout)
|
||||
}
|
||||
var control map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout[len(marker):]), &control); err != nil {
|
||||
t.Fatalf("decode Cline control output: %v", err)
|
||||
}
|
||||
return control
|
||||
}
|
||||
@@ -354,11 +354,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;
|
||||
|
||||
@@ -12,6 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
||||
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
|
||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
||||
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
|
||||
"cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"},
|
||||
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
|
||||
@@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin {
|
||||
return &ClaudeCodePlugin{}
|
||||
case "codex":
|
||||
return &CodexPlugin{}
|
||||
case "cline":
|
||||
return &ClinePlugin{}
|
||||
case "cursor":
|
||||
return &CursorPlugin{}
|
||||
case "gemini":
|
||||
|
||||
Reference in new issue
Block a user