242 lines
8.7 KiB
Go
242 lines
8.7 KiB
Go
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)
|
|
}
|
|
plugin, ok := NewPlugin("cline").(*ClinePlugin)
|
|
if !ok {
|
|
t.Fatalf("Cline plugin is not registered")
|
|
}
|
|
if !plugin.ManagesGuardRuntime() {
|
|
t.Fatal("Cline plugin must own its runtime transaction")
|
|
}
|
|
}
|
|
|
|
func TestClineInstallWritesAllSixManagedFilesAtomicallyAndIsIdempotent(t *testing.T) {
|
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
|
installClineFixture(t, fixture)
|
|
files := []string{
|
|
fixture.guardPath,
|
|
fixture.runtimeConfig,
|
|
fixture.privateKey,
|
|
fixture.hookPath,
|
|
fixture.guardWrapper,
|
|
fixture.auditWrapper,
|
|
}
|
|
original := make(map[string]string, len(files))
|
|
for _, path := range files {
|
|
original[path] = readClineTestFile(t, path)
|
|
}
|
|
|
|
installClineFixture(t, fixture)
|
|
|
|
for path, source := range original {
|
|
if readClineTestFile(t, path) != source {
|
|
t.Fatalf("%s changed during idempotent install", path)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(original[fixture.guardWrapper], "#!/usr/bin/env node\n// @elydora-cline-hook ") {
|
|
t.Fatalf("guard wrapper has unexpected header: %q", original[fixture.guardWrapper])
|
|
}
|
|
if original[fixture.guardPath] != generateGuardScript(
|
|
clineAgentKey,
|
|
clineTestAgentID,
|
|
"",
|
|
false,
|
|
"",
|
|
) {
|
|
t.Fatal("guard runtime does not match the generated source")
|
|
}
|
|
if original[fixture.hookPath] != buildHookScriptWithOutput(
|
|
clineAgentKey,
|
|
clineTestAgentID,
|
|
"",
|
|
false,
|
|
true,
|
|
) {
|
|
t.Fatal("audit runtime does not preserve the native payload")
|
|
}
|
|
if original[fixture.privateKey] != clinePrivateKey {
|
|
t.Fatal("private key changed during installation")
|
|
}
|
|
config := readClineTestObject(t, fixture.runtimeConfig)
|
|
wantConfig := map[string]any{
|
|
"org_id": "org-1", "agent_id": clineTestAgentID, "kid": "kid-1",
|
|
"base_url": "https://api.elydora.test", "token": "token-1",
|
|
"agent_name": clineAgentKey,
|
|
}
|
|
if !reflect.DeepEqual(config, wantConfig) {
|
|
t.Fatalf("runtime config = %#v", config)
|
|
}
|
|
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
|
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)
|
|
if err := os.WriteFile(
|
|
fixture.guardPath,
|
|
[]byte("process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"),
|
|
0700,
|
|
); err != nil {
|
|
t.Fatalf("write frozen guard runtime: %v", err)
|
|
}
|
|
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)
|
|
payloadObject := map[string]any{
|
|
"clineVersion": "3.0.46",
|
|
"hookName": "tool_result",
|
|
"timestamp": "2026-07-19T12:00:00.000Z",
|
|
"taskId": "task-1",
|
|
"workspaceRoots": []any{
|
|
fixture.workspaceDir,
|
|
},
|
|
"userId": "user-1",
|
|
"agent_id": "cline-agent",
|
|
"parent_agent_id": nil,
|
|
"tool_result": map[string]any{
|
|
"id": "call-1", "name": "read_file",
|
|
"input": map[string]any{"path": "README.md"},
|
|
"output": "ok", "durationMs": float64(5),
|
|
},
|
|
"postToolUse": map[string]any{
|
|
"toolName": "read_file",
|
|
"parameters": map[string]any{"path": "README.md"},
|
|
"result": "ok", "success": true, "executionTimeMs": float64(5),
|
|
},
|
|
}
|
|
payload, err := json.Marshal(payloadObject)
|
|
if err != nil {
|
|
t.Fatalf("marshal Cline payload: %v", err)
|
|
}
|
|
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")
|
|
}
|
|
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), payloadObject) {
|
|
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{})
|
|
installClineFixture(t, passing)
|
|
if err := os.WriteFile(passing.guardPath, []byte("process.stdin.resume();\n"), 0700); err != nil {
|
|
t.Fatalf("write passing guard runtime: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|