fix(cline): mirror transactional Go adapter
This commit is contained in:
1 parent
052116081a
commit
d0185887fa
14 files changed
+1681
-484
No files matched your search
@@ -30,7 +30,7 @@ This repository publishes the open-source server, console, integration catalog,
|
||||
- Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit each SDK's Gemini CLI user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects.
|
||||
- Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`.
|
||||
- All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`.
|
||||
- Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux.
|
||||
- Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node and Go wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux.
|
||||
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes.
|
||||
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction.
|
||||
- Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control.
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` comm
|
||||
|
||||
GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false.
|
||||
|
||||
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.
|
||||
Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const clineCLIPrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
|
||||
|
||||
func clineCLIRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, sourceFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve Cline CLI test source path")
|
||||
}
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", ".."))
|
||||
}
|
||||
|
||||
func buildClineCLIBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
binary := filepath.Join(t.TempDir(), "elydora")
|
||||
if runtime.GOOS == "windows" {
|
||||
binary += ".exe"
|
||||
}
|
||||
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
|
||||
build.Dir = clineCLIRepository(t)
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
func runClineCLI(
|
||||
t *testing.T,
|
||||
binary string,
|
||||
workingDirectory string,
|
||||
environment []string,
|
||||
args ...string,
|
||||
) (string, string, int) {
|
||||
t.Helper()
|
||||
process := exec.Command(binary, args...)
|
||||
process.Dir = workingDirectory
|
||||
process.Env = append(os.Environ(), environment...)
|
||||
var stdout, stderr strings.Builder
|
||||
process.Stdout = &stdout
|
||||
process.Stderr = &stderr
|
||||
err := process.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), stderr.String(), 0
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return stdout.String(), stderr.String(), exitError.ExitCode()
|
||||
}
|
||||
t.Fatalf("run Elydora CLI: %v", err)
|
||||
return "", "", -1
|
||||
}
|
||||
|
||||
func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home")
|
||||
clineDir := filepath.Join(root, "cline-home")
|
||||
workspace := filepath.Join(root, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0700); err != nil {
|
||||
t.Fatalf("create Cline CLI workspace: %v", err)
|
||||
}
|
||||
environment := []string{
|
||||
"HOME=" + homeDir,
|
||||
"USERPROFILE=" + homeDir,
|
||||
"CLINE_DIR=" + clineDir,
|
||||
}
|
||||
return homeDir, clineDir, workspace, environment
|
||||
}
|
||||
|
||||
func writeClineCLISecret(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(value+"\n"), 0600); err != nil {
|
||||
t.Fatalf("write Cline CLI secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineCLIPreflightPreservesCollisionBeforeRuntimeCreation(t *testing.T) {
|
||||
binary := buildClineCLIBinary(t)
|
||||
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
|
||||
hooksDir := filepath.Join(clineDir, "hooks")
|
||||
auditPath := filepath.Join(hooksDir, "PostToolUse.mjs")
|
||||
if err := os.MkdirAll(hooksDir, 0700); err != nil {
|
||||
t.Fatalf("create Cline hooks directory: %v", err)
|
||||
}
|
||||
original := "// user PostToolUse hook\n"
|
||||
if err := os.WriteFile(auditPath, []byte(original), 0600); err != nil {
|
||||
t.Fatalf("write user Cline hook: %v", err)
|
||||
}
|
||||
keyPath := filepath.Join(t.TempDir(), "private.key")
|
||||
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
|
||||
_, stderr, exit := runClineCLI(
|
||||
t,
|
||||
binary,
|
||||
workspace,
|
||||
environment,
|
||||
"install",
|
||||
"--agent", "cline",
|
||||
"--org-id", "org-1",
|
||||
"--agent-id", "agent-1",
|
||||
"--private-key-file", keyPath,
|
||||
"--kid", "kid-1",
|
||||
"--base-url", "https://api.elydora.test",
|
||||
)
|
||||
if exit == 0 || !strings.Contains(stderr, "owned by another integration") {
|
||||
t.Fatalf("CLI preflight = %d, %q", exit, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(auditPath)
|
||||
if err != nil || string(raw) != original {
|
||||
t.Fatalf("user Cline hook changed: %q, %v", raw, err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(homeDir, ".elydora")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("runtime directory created during failed preflight: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(hooksDir, "PreToolUse.mjs")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("guard hook created during failed preflight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
|
||||
binary := buildClineCLIBinary(t)
|
||||
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
|
||||
keyPath := filepath.Join(t.TempDir(), "private.key")
|
||||
tokenPath := filepath.Join(t.TempDir(), "token")
|
||||
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
|
||||
writeClineCLISecret(t, tokenPath, "token-1")
|
||||
stdout, stderr, exit := runClineCLI(
|
||||
t,
|
||||
binary,
|
||||
workspace,
|
||||
environment,
|
||||
"install",
|
||||
"--agent", "cline",
|
||||
"--org-id", "org-1",
|
||||
"--agent-id", "agent-1",
|
||||
"--private-key-file", keyPath,
|
||||
"--token-file", tokenPath,
|
||||
"--kid", "kid-1",
|
||||
"--base-url", "http://127.0.0.1:9",
|
||||
)
|
||||
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Cline") {
|
||||
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
|
||||
}
|
||||
agentDir := filepath.Join(homeDir, ".elydora", "agent-1")
|
||||
for _, path := range []string{
|
||||
filepath.Join(agentDir, "guard.js"),
|
||||
filepath.Join(agentDir, "config.json"),
|
||||
filepath.Join(agentDir, "private.key"),
|
||||
filepath.Join(agentDir, "hook.js"),
|
||||
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
|
||||
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("installed file missing at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
stdout, stderr, exit = runClineCLI(
|
||||
t, binary, workspace, environment, "status", "--agent", "cline",
|
||||
)
|
||||
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
|
||||
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
|
||||
}
|
||||
stdout, stderr, exit = runClineCLI(
|
||||
t,
|
||||
binary,
|
||||
workspace,
|
||||
environment,
|
||||
"uninstall",
|
||||
"--agent", "cline",
|
||||
"--agent-id", "agent-1",
|
||||
)
|
||||
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
|
||||
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
|
||||
}
|
||||
for _, path := range []string{
|
||||
agentDir,
|
||||
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
|
||||
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
|
||||
} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed path remains at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,83 +3,61 @@ package plugins
|
||||
import "fmt"
|
||||
|
||||
// ClinePlugin manages Cline's native global file hooks.
|
||||
type ClinePlugin struct{}
|
||||
type ClinePlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
|
||||
// both native hook files in one transaction.
|
||||
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PreflightInstall validates hook ownership and runtime identity before writes.
|
||||
func (p *ClinePlugin) PreflightInstall(config InstallConfig) error {
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAvailableClineHook(guard); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAvailableClineHook(audit); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = preflightClineInstallation(config, paths)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *ClinePlugin) Install(config InstallConfig) error {
|
||||
if config.AgentID == "" {
|
||||
return fmt.Errorf("agent ID is required")
|
||||
}
|
||||
paths, err := resolveClineHookFiles()
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guardState, err := readClineHookFile(paths.guardPath)
|
||||
if err := requireAvailableClineHook(guard); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAvailableClineHook(audit); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimePaths, err := preflightClineInstallation(config, paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditState, err := readClineHookFile(paths.auditPath)
|
||||
changes, err := prepareClineInstallationChanges(
|
||||
config,
|
||||
runtimePaths,
|
||||
guard,
|
||||
audit,
|
||||
)
|
||||
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},
|
||||
if err := writeClineChanges(
|
||||
changes,
|
||||
"Install Cline hooks",
|
||||
p.rename,
|
||||
runtimePaths,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,23 +66,27 @@ func (p *ClinePlugin) Install(config InstallConfig) error {
|
||||
}
|
||||
|
||||
func (p *ClinePlugin) Uninstall(agentID string) error {
|
||||
paths, err := resolveClineHookFiles()
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guardState, err := readClineHookFile(paths.guardPath)
|
||||
changes, err := prepareClineUninstallChanges(
|
||||
[]clineHookFile{guard, audit},
|
||||
agentID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auditState, err := readClineHookFile(paths.auditPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID)
|
||||
return writeClineHookChanges(
|
||||
changes,
|
||||
"Uninstall Cline hooks",
|
||||
p.rename,
|
||||
paths,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *ClinePlugin) Status() (PluginStatus, error) {
|
||||
paths, err := resolveClineHookFiles()
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
status := PluginStatus{
|
||||
AgentName: clineAgentKey,
|
||||
DisplayName: "Cline",
|
||||
@@ -113,15 +95,7 @@ func (p *ClinePlugin) Status() (PluginStatus, error) {
|
||||
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)
|
||||
contract, err := clineContractForFiles(guard, audit)
|
||||
if err != nil || contract == nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type clineHookFile struct {
|
||||
}
|
||||
|
||||
type clineHookPaths struct {
|
||||
clineDirectory string
|
||||
hooksDirectory string
|
||||
guardPath string
|
||||
auditPath string
|
||||
@@ -55,14 +56,6 @@ func clineHomeDirectory() (string, error) {
|
||||
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 == "" {
|
||||
@@ -80,6 +73,7 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
||||
}
|
||||
hooksDirectory := filepath.Join(clineDirectory, "hooks")
|
||||
return clineHookPaths{
|
||||
clineDirectory: clineDirectory,
|
||||
hooksDirectory: hooksDirectory,
|
||||
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
|
||||
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
|
||||
@@ -225,7 +219,7 @@ async function main() {
|
||||
|
||||
if (hookKind === 'guard' && result.code === 2) {
|
||||
const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.';
|
||||
process.stdout.write('HOOK_CONTROL\t' + JSON.stringify({ cancel: true, errorMessage }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ cancel: true, errorMessage }) + '\n');
|
||||
return;
|
||||
}
|
||||
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
|
||||
@@ -303,13 +297,6 @@ func assertClineWrapperIntegrity(file clineHookFile) error {
|
||||
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) {
|
||||
@@ -330,14 +317,10 @@ func clineContractForFiles(
|
||||
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()
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId: %w", err)
|
||||
}
|
||||
agentDirectory := filepath.Join(runtimeRoot, guard.AgentID)
|
||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type clineRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
hooks clineHookPaths
|
||||
}
|
||||
|
||||
func validateClineInstallConfig(config InstallConfig) error {
|
||||
for _, field := range []struct{ name, value string }{
|
||||
{"agent name", config.AgentName},
|
||||
{"organization ID", config.OrgID},
|
||||
{"agent ID", config.AgentID},
|
||||
{"key ID", config.KID},
|
||||
{"private key", config.PrivateKey},
|
||||
{"base URL", config.BaseURL},
|
||||
{"guard script path", config.GuardScriptPath},
|
||||
} {
|
||||
if field.value == "" {
|
||||
return fmt.Errorf("%s is required", field.name)
|
||||
}
|
||||
}
|
||||
if config.AgentName != clineAgentKey {
|
||||
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
|
||||
}
|
||||
if strings.TrimSpace(config.OrgID) == "" {
|
||||
return fmt.Errorf("organization ID is required")
|
||||
}
|
||||
if strings.TrimSpace(config.KID) == "" {
|
||||
return fmt.Errorf("key ID is required")
|
||||
}
|
||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||
}
|
||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateManagedBaseURL(config.BaseURL)
|
||||
}
|
||||
|
||||
func clineAgentPaths(
|
||||
config InstallConfig,
|
||||
hooks clineHookPaths,
|
||||
) (*clineRuntimePaths, error) {
|
||||
if err := validateClineInstallConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := &clineRuntimePaths{
|
||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
|
||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||
guardPath: filepath.Join(agentDirectory, clineGuardScript),
|
||||
auditPath: filepath.Join(agentDirectory, clineAuditScript),
|
||||
}
|
||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
|
||||
return nil, fmt.Errorf(
|
||||
"Elydora guard runtime must use the managed agent directory: %s",
|
||||
paths.guardPath,
|
||||
)
|
||||
}
|
||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
|
||||
return nil, fmt.Errorf(
|
||||
"Elydora audit runtime must use the managed agent directory: %s",
|
||||
paths.auditPath,
|
||||
)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func sameClinePathValue(left, right string) bool {
|
||||
matches, err := sameClinePath(left, right)
|
||||
return err == nil && matches
|
||||
}
|
||||
|
||||
func preflightClineInstallation(
|
||||
config InstallConfig,
|
||||
hooks clineHookPaths,
|
||||
) (*clineRuntimePaths, error) {
|
||||
paths, err := clineAgentPaths(config, hooks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
||||
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) {
|
||||
value := map[string]any{
|
||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||
"base_url": config.BaseURL, "agent_name": clineAgentKey,
|
||||
}
|
||||
if config.Token != "" {
|
||||
value["token"] = config.Token
|
||||
}
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
if len(encoded) > maxRuntimeConfigBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
||||
maxRuntimeConfigBytes,
|
||||
)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func prepareClineInstallationChanges(
|
||||
config InstallConfig,
|
||||
paths *clineRuntimePaths,
|
||||
guardFile clineHookFile,
|
||||
auditFile clineHookFile,
|
||||
) ([]*fileChange, error) {
|
||||
if paths == nil {
|
||||
return nil, fmt.Errorf("prepared Cline installation is required")
|
||||
}
|
||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeConfig, err := buildClineRuntimeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Cline guard metadata: %w", err)
|
||||
}
|
||||
auditMetadata, err := buildClineMetadata("audit", config.AgentID, paths.auditPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Cline audit metadata: %w", err)
|
||||
}
|
||||
guardWrapper, err := buildClineWrapper(guardMetadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Cline guard wrapper: %w", err)
|
||||
}
|
||||
auditWrapper, err := buildClineWrapper(auditMetadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Cline audit wrapper: %w", err)
|
||||
}
|
||||
if _, err := clineContractForFiles(
|
||||
clineHookFile{exists: true, filePath: guardFile.filePath, source: guardWrapper, metadata: &guardMetadata},
|
||||
clineHookFile{exists: true, filePath: auditFile.filePath, source: auditWrapper, metadata: &auditMetadata},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeItems := []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
|
||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
|
||||
}
|
||||
changes := make([]*fileChange, 0, len(runtimeItems)+2)
|
||||
for _, item := range runtimeItems {
|
||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
file clineHookFile
|
||||
label string
|
||||
source string
|
||||
}{
|
||||
{guardFile, "Cline PreToolUse hook", guardWrapper},
|
||||
{auditFile, "Cline PostToolUse hook", auditWrapper},
|
||||
} {
|
||||
change, err := prepareSourceChange(
|
||||
item.file.filePath,
|
||||
item.label,
|
||||
[]byte(item.file.source),
|
||||
item.file.exists,
|
||||
[]byte(item.source),
|
||||
0700,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
@@ -1,47 +1,63 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type clinePendingWrite struct {
|
||||
state clineHookFile
|
||||
source string
|
||||
func validateClineHookDirectories(paths clineHookPaths) error {
|
||||
if _, err := managedPhysicalDirectoryExists(
|
||||
paths.clineDirectory,
|
||||
"Cline configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := managedPhysicalDirectoryExists(
|
||||
paths.hooksDirectory,
|
||||
"Cline hooks directory",
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
snapshot, err := readManagedFile(filePath, "Cline hook", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return clineHookFile{filePath: filePath}, nil
|
||||
}
|
||||
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err)
|
||||
return clineHookFile{}, err
|
||||
}
|
||||
source := string(raw)
|
||||
if snapshot == nil {
|
||||
return clineHookFile{filePath: filePath}, nil
|
||||
}
|
||||
source := string(snapshot.contents)
|
||||
metadata, err := parseClineMetadata(filePath, source)
|
||||
if err != nil {
|
||||
return clineHookFile{}, err
|
||||
}
|
||||
return clineHookFile{
|
||||
exists: true,
|
||||
filePath: filePath,
|
||||
source: source,
|
||||
metadata: metadata,
|
||||
exists: true, filePath: filePath, source: source, metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readClineHookPair() (
|
||||
clineHookPaths,
|
||||
clineHookFile,
|
||||
clineHookFile,
|
||||
error,
|
||||
) {
|
||||
paths, err := resolveClineHookFiles()
|
||||
if err != nil {
|
||||
return clineHookPaths{}, clineHookFile{}, clineHookFile{}, err
|
||||
}
|
||||
if err := validateClineHookDirectories(paths); err != nil {
|
||||
return paths, clineHookFile{}, clineHookFile{}, err
|
||||
}
|
||||
guard, err := readClineHookFile(paths.guardPath)
|
||||
if err != nil {
|
||||
return paths, clineHookFile{}, clineHookFile{}, err
|
||||
}
|
||||
audit, err := readClineHookFile(paths.auditPath)
|
||||
if err != nil {
|
||||
return paths, clineHookFile{}, clineHookFile{}, err
|
||||
}
|
||||
return paths, guard, audit, nil
|
||||
}
|
||||
|
||||
func requireAvailableClineHook(file clineHookFile) error {
|
||||
if file.exists && file.metadata == nil {
|
||||
return fmt.Errorf(
|
||||
@@ -52,160 +68,80 @@ func requireAvailableClineHook(file clineHookFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeClineTemporary(path string) error {
|
||||
if path == "" {
|
||||
func writeClineHookChanges(
|
||||
changes []*fileChange,
|
||||
label string,
|
||||
rename renameFunc,
|
||||
paths clineHookPaths,
|
||||
) error {
|
||||
if !hasClineChanges(changes) {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
|
||||
if err := ensureManagedDirectory(
|
||||
paths.clineDirectory,
|
||||
"Cline configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
if err := ensureManagedDirectory(paths.hooksDirectory, "Cline hooks directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeChanges(changes, label, rename)
|
||||
}
|
||||
|
||||
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,
|
||||
func writeClineChanges(
|
||||
changes []*fileChange,
|
||||
label string,
|
||||
rename renameFunc,
|
||||
paths *clineRuntimePaths,
|
||||
) 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)
|
||||
if !hasClineChanges(changes) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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++
|
||||
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
if err := EnsurePrivateDirectory(paths.agentDirectory); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClineHookChanges(changes, label, rename, paths.hooks)
|
||||
}
|
||||
|
||||
func removeOwnedClineHooks(files []clineHookFile, agentID string) error {
|
||||
func hasClineChanges(changes []*fileChange) bool {
|
||||
for _, change := range changes {
|
||||
if change != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prepareClineUninstallChanges(
|
||||
files []clineHookFile,
|
||||
agentID string,
|
||||
) ([]*fileChange, error) {
|
||||
changes := make([]*fileChange, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file.metadata == nil {
|
||||
if file.metadata == nil ||
|
||||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) {
|
||||
continue
|
||||
}
|
||||
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
|
||||
continue
|
||||
if err := assertClineWrapperIntegrity(file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
|
||||
return err
|
||||
change, err := prepareSourceChange(
|
||||
file.filePath,
|
||||
fmt.Sprintf("Cline %s hook", file.metadata.Kind),
|
||||
[]byte(file.source),
|
||||
file.exists,
|
||||
nil,
|
||||
0700,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
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
|
||||
return changes, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runOfficialCline(
|
||||
t *testing.T,
|
||||
fixture *clineFixture,
|
||||
nodePath string,
|
||||
args ...string,
|
||||
) (string, error) {
|
||||
t.Helper()
|
||||
process := exec.Command(nodePath, args...)
|
||||
process.Dir = fixture.workspaceDir
|
||||
process.Env = append(
|
||||
os.Environ(),
|
||||
"HOME="+fixture.homeDir,
|
||||
"USERPROFILE="+fixture.homeDir,
|
||||
"CLINE_DIR="+fixture.clineDir,
|
||||
)
|
||||
output, err := process.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func TestClineOfficialLoaderDiscoversBothManagedFileHooks(t *testing.T) {
|
||||
coreEntry := os.Getenv("ELYDORA_CLINE_CORE_ENTRY")
|
||||
clineEntry := os.Getenv("ELYDORA_CLINE_ENTRY")
|
||||
if coreEntry == "" || clineEntry == "" {
|
||||
t.Skip("set ELYDORA_CLINE_CORE_ENTRY and ELYDORA_CLINE_ENTRY")
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||
}
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
version, err := runOfficialCline(t, fixture, nodePath, clineEntry, "--version")
|
||||
foundVersion := false
|
||||
for _, line := range strings.Split(strings.ReplaceAll(version, "\r\n", "\n"), "\n") {
|
||||
foundVersion = foundVersion || strings.TrimSpace(line) == "3.0.46"
|
||||
}
|
||||
if err != nil || !foundVersion {
|
||||
t.Fatalf("official Cline version = %q, %v", version, err)
|
||||
}
|
||||
source := `
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { listHookConfigFiles } = await import(pathToFileURL(process.env.ELYDORA_CLINE_CORE_ENTRY).href);
|
||||
console.log(JSON.stringify(listHookConfigFiles(process.env.ELYDORA_WORKSPACE)));
|
||||
`
|
||||
process := exec.Command(nodePath, "--input-type=module", "--eval", source)
|
||||
process.Dir = fixture.workspaceDir
|
||||
process.Env = append(
|
||||
os.Environ(),
|
||||
"HOME="+fixture.homeDir,
|
||||
"USERPROFILE="+fixture.homeDir,
|
||||
"CLINE_DIR="+fixture.clineDir,
|
||||
"ELYDORA_CLINE_CORE_ENTRY="+coreEntry,
|
||||
"ELYDORA_WORKSPACE="+fixture.workspaceDir,
|
||||
)
|
||||
output, err := process.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("official Cline hook discovery: %v\n%s", err, output)
|
||||
}
|
||||
var actual []map[string]any
|
||||
if err := json.Unmarshal(output, &actual); err != nil {
|
||||
t.Fatalf("decode official Cline hook discovery: %v\n%s", err, output)
|
||||
}
|
||||
expected := []map[string]any{
|
||||
{"fileName": "PostToolUse", "hookEventName": "tool_result", "path": fixture.auditWrapper},
|
||||
{"fileName": "PreToolUse", "hookEventName": "tool_call", "path": fixture.guardWrapper},
|
||||
}
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Fatalf("official Cline hooks = %#v", actual)
|
||||
}
|
||||
}
|
||||
@@ -8,119 +8,174 @@ import (
|
||||
"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")
|
||||
func TestClineStatusRequiresExactPhysicalInstallation(t *testing.T) {
|
||||
for _, target := range []string{
|
||||
"config", "key", "guard", "audit", "guard-wrapper", "audit-wrapper",
|
||||
} {
|
||||
t.Run("missing "+target, func(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)
|
||||
}
|
||||
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)
|
||||
paths := map[string]string{
|
||||
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
"guard-wrapper": fixture.guardWrapper,
|
||||
"audit-wrapper": fixture.auditWrapper,
|
||||
}
|
||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
if err := os.Remove(paths[target]); err != nil {
|
||||
t.Fatalf("remove %s: %v", target, err)
|
||||
}
|
||||
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
|
||||
t.Fatalf("user guard wrapper changed")
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookScriptExists {
|
||||
t.Fatalf("missing %s status = %#v, %v", target, status, err)
|
||||
}
|
||||
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
|
||||
t.Fatalf("user audit wrapper changed")
|
||||
if strings.Contains(target, "wrapper") && status.HookConfigured {
|
||||
t.Fatalf("missing wrapper remains configured: %#v", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func TestClineStatusSurfacesTamperingAndInvalidRuntimeMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, target, source, want string
|
||||
}{
|
||||
{"guard-source", "guard", "tampered\n", ""},
|
||||
{"audit-source", "audit", "tampered\n", ""},
|
||||
{"private-key", "key", "invalid", "canonical 32-byte"},
|
||||
{"config-malformed", "config", "{ malformed", "parse Elydora runtime config"},
|
||||
{"config-duplicate", "config", `{"agent_name":"cline","agent_name":"cline"}`, "duplicate key"},
|
||||
{"config-unsupported", "config", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"cline","extra":true}`, "unsupported field"},
|
||||
{"config-identity", "config", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"cline"}`, "identity does not match"},
|
||||
{"guard-wrapper", "guard-wrapper", "tamper", "managed template"},
|
||||
}
|
||||
if readClineTestFile(t, fixture.guardWrapper) != corrupt {
|
||||
t.Fatalf("corrupt hook changed")
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
paths := map[string]string{
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
"key": fixture.privateKey, "config": fixture.runtimeConfig,
|
||||
"guard-wrapper": fixture.guardWrapper,
|
||||
}
|
||||
source := testCase.source
|
||||
if testCase.target == "guard-wrapper" {
|
||||
source = readClineTestFile(t, paths[testCase.target]) + testCase.source
|
||||
}
|
||||
writeClineTestFile(t, paths[testCase.target], []byte(source), 0600)
|
||||
status, err := fixture.plugin.Status()
|
||||
if testCase.want == "" {
|
||||
if err != nil || status.Installed || status.HookScriptExists {
|
||||
t.Fatalf("tampered status = %#v, %v", status, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("status error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
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,
|
||||
func TestClineStatusRejectsInvalidHookContracts(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name, kind, agentID, runtimePath, want string
|
||||
}{
|
||||
{"kind", "guard", clineTestAgentID, "", "mismatched event metadata"},
|
||||
{"agent", "audit", "agent-2", "", "different agents"},
|
||||
{"path", "audit", clineTestAgentID, "outside", "unexpected runtime path"},
|
||||
{"segment", "audit", "..", "", "invalid agentId"},
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
runtimePath := testCase.runtimePath
|
||||
if runtimePath == "outside" {
|
||||
runtimePath = filepath.Join(filepath.Dir(filepath.Dir(fixture.agentDir)), "outside", clineAuditScript)
|
||||
}
|
||||
if runtimePath == "" {
|
||||
runtimePath = fixture.hookPath
|
||||
if testCase.agentID == "agent-2" {
|
||||
runtimePath = filepath.Join(filepath.Dir(fixture.agentDir), "agent-2", clineAuditScript)
|
||||
}
|
||||
}
|
||||
metadata, err := buildClineMetadata(
|
||||
testCase.kind,
|
||||
testCase.agentID,
|
||||
runtimePath,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build invalid contract metadata: %v", err)
|
||||
}
|
||||
source, err := buildClineWrapper(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("build invalid contract wrapper: %v", err)
|
||||
}
|
||||
writeClineTestFile(t, fixture.auditWrapper, []byte(source), 0700)
|
||||
if testCase.name == "segment" {
|
||||
guardMetadata, buildErr := buildClineMetadata(
|
||||
"guard",
|
||||
testCase.agentID,
|
||||
fixture.guardPath,
|
||||
)
|
||||
if buildErr != nil {
|
||||
t.Fatalf("build invalid guard metadata: %v", buildErr)
|
||||
}
|
||||
guardSource, buildErr := buildClineWrapper(guardMetadata)
|
||||
if buildErr != nil {
|
||||
t.Fatalf("build invalid guard wrapper: %v", buildErr)
|
||||
}
|
||||
writeClineTestFile(t, fixture.guardWrapper, []byte(guardSource), 0700)
|
||||
}
|
||||
_, err = fixture.plugin.Status()
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("contract error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) {
|
||||
func TestClineInstallRejectsCollisionsAndCorruptOwnershipBeforeWrites(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
opts clineFixtureOptions
|
||||
want string
|
||||
}{
|
||||
{"guard-collision", clineFixtureOptions{ExistingGuard: clineString("// user PreToolUse hook\n")}, "owned by another integration"},
|
||||
{"audit-collision", clineFixtureOptions{ExistingAudit: clineString("// user PostToolUse hook\n")}, "owned by another integration"},
|
||||
{"corrupt-metadata", clineFixtureOptions{ExistingGuard: clineString("#!/usr/bin/env node\n// @elydora-cline-hook invalid\n")}, "parse Elydora Cline hook metadata"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, testCase.opts)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for path, expected := range map[string]*string{
|
||||
fixture.guardWrapper: testCase.opts.ExistingGuard,
|
||||
fixture.auditWrapper: testCase.opts.ExistingAudit,
|
||||
} {
|
||||
if expected != nil && readClineTestFile(t, path) != *expected {
|
||||
t.Fatalf("existing hook changed at %s", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRemovesExactOwnershipAndPreservesAdjacentHooks(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)
|
||||
}
|
||||
writeClineTestFile(t, userHook, []byte("# user hook\n"), 0600)
|
||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
||||
t.Fatalf("uninstall other agent: %v", err)
|
||||
}
|
||||
@@ -129,78 +184,21 @@ func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T)
|
||||
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Cline hooks: %v", err)
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
for _, path := range []string{fixture.guardWrapper, fixture.auditWrapper} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed hook remains at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
if readClineTestFile(t, userHook) != "# user hook\n" {
|
||||
t.Fatalf("user hook changed")
|
||||
t.Fatal("adjacent user hook changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) {
|
||||
func TestClineRuntimeConfigOmitsEmptyToken(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
fixture.config.Token = ""
|
||||
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())
|
||||
}
|
||||
if _, exists := readClineTestObject(t, fixture.runtimeConfig)["token"]; exists {
|
||||
t.Fatal("empty token persisted")
|
||||
}
|
||||
}
|
||||
|
||||
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,96 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func clineOfficialPayload(event string) map[string]any {
|
||||
payload := map[string]any{
|
||||
"clineVersion": "3.0.46",
|
||||
"hookName": event,
|
||||
"timestamp": "2026-07-19T12:00:00.000Z",
|
||||
"taskId": "task-1",
|
||||
"workspaceRoots": []any{
|
||||
"C:/workspace",
|
||||
},
|
||||
"userId": "user-1",
|
||||
"agent_id": "cline-agent",
|
||||
"parent_agent_id": nil,
|
||||
}
|
||||
if event == "tool_call" {
|
||||
payload["tool_call"] = map[string]any{
|
||||
"id": "call-1", "name": "read_file",
|
||||
"input": map[string]any{"path": "README.md"},
|
||||
}
|
||||
payload["preToolUse"] = map[string]any{
|
||||
"toolName": "read_file",
|
||||
"parameters": map[string]any{"path": "README.md"},
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func marshalClinePayload(t *testing.T, payload map[string]any) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Cline payload: %v", err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func installClineRuntimeFixture(
|
||||
t *testing.T,
|
||||
status string,
|
||||
) (*clineFixture, *codexTestAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
api := &codexTestAPI{status: status}
|
||||
server := httptest.NewServer(api)
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
fixture.config.BaseURL = server.URL
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
server.Close()
|
||||
t.Fatalf("install Cline hooks: %v", err)
|
||||
}
|
||||
return fixture, api, server
|
||||
}
|
||||
|
||||
func TestClineGeneratedGuardEmitsPureCancellationForFrozenAndRevokedAgents(t *testing.T) {
|
||||
for _, status := range []string{"frozen", "revoked"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
fixture, _, server := installClineRuntimeFixture(t, status)
|
||||
defer server.Close()
|
||||
result := runClineWrapper(
|
||||
t,
|
||||
fixture,
|
||||
fixture.guardWrapper,
|
||||
marshalClinePayload(t, clineOfficialPayload("tool_call")),
|
||||
)
|
||||
control := decodeClineControl(t, result.stdout)
|
||||
if result.exitCode != 0 || control["cancel"] != true ||
|
||||
!strings.Contains(strings.ToLower(result.stderr), status) ||
|
||||
!strings.Contains(result.stderr, "Tool execution blocked") {
|
||||
t.Fatalf("guard result = %#v, %#v", result, control)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineGeneratedRuntimesKeepFailuresObservableAndFailOpen(t *testing.T) {
|
||||
fixture, _, server := installClineRuntimeFixture(t, "active")
|
||||
payload := marshalClinePayload(t, clineOfficialPayload("tool_call"))
|
||||
server.Close()
|
||||
guard := runClineWrapper(t, fixture, fixture.guardWrapper, payload)
|
||||
audit := runClineWrapper(t, fixture, fixture.auditWrapper, []byte("{ malformed"))
|
||||
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Failed to resolve agent status") ||
|
||||
audit.exitCode != 0 || err != nil ||
|
||||
!strings.Contains(strings.ToLower(string(log)), "invalid json") {
|
||||
t.Fatalf("fail-open results = %#v, %#v, log=%q, err=%v", guard, audit, log, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readClineRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||
if err != nil || snapshot == nil {
|
||||
return nil, snapshot != nil, err
|
||||
}
|
||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||
return config, true, err
|
||||
}
|
||||
|
||||
func requireClineRuntimeString(
|
||||
config map[string]any,
|
||||
field string,
|
||||
configPath string,
|
||||
) (string, error) {
|
||||
value, ok := config[field].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf(
|
||||
"Elydora runtime config %s is invalid: %s",
|
||||
field,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateClineRuntimeConfig(
|
||||
config map[string]any,
|
||||
expectedAgentID string,
|
||||
configPath string,
|
||||
) error {
|
||||
supported := stringSet(
|
||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
||||
)
|
||||
fields := make([]string, 0, len(config))
|
||||
for field := range config {
|
||||
fields = append(fields, field)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
for _, field := range fields {
|
||||
if _, ok := supported[field]; !ok {
|
||||
return fmt.Errorf(
|
||||
`Elydora runtime config has unsupported field %q: %s`,
|
||||
field,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
agentID, err := requireClineRuntimeString(config, "agent_id", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime identity does not match Cline hooks: %s",
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
if _, exists := config["token"]; exists {
|
||||
if _, err := requireClineRuntimeString(config, "token", configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
baseURL, err := requireClineRuntimeString(config, "base_url", configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime config base URL is invalid at %s: %w",
|
||||
configPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateClineRuntimeIdentity(agentDirectory, agentID string) error {
|
||||
runtimeRoot := filepath.Dir(agentDirectory)
|
||||
rootExists, err := managedPhysicalDirectoryExists(
|
||||
runtimeRoot,
|
||||
"Elydora runtime directory",
|
||||
)
|
||||
if err != nil || !rootExists {
|
||||
return err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
agentDirectory,
|
||||
"Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return err
|
||||
}
|
||||
configPath := filepath.Join(agentDirectory, "config.json")
|
||||
config, configExists, err := readClineRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
artifactExists := false
|
||||
for _, item := range []struct {
|
||||
path string
|
||||
label string
|
||||
limit int64
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||
{filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||
} {
|
||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||
if inspectErr != nil {
|
||||
return inspectErr
|
||||
}
|
||||
artifactExists = artifactExists || exists
|
||||
}
|
||||
if !configExists {
|
||||
if artifactExists {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
||||
agentDirectory,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
configuredID, ok := config["agent_id"].(string)
|
||||
if !ok || config["agent_name"] != clineAgentKey ||
|
||||
!sameClineAgentID(configuredID, agentID) {
|
||||
return fmt.Errorf(
|
||||
"Elydora runtime config identity does not match Cline agent %s: %s",
|
||||
agentID,
|
||||
configPath,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) ||
|
||||
!sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) ||
|
||||
!sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) {
|
||||
return false, nil
|
||||
}
|
||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||
if err != nil || !rootExists {
|
||||
return false, err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
contract.agentDirectory,
|
||||
"Elydora agent runtime directory",
|
||||
)
|
||||
if err != nil || !directoryExists {
|
||||
return false, err
|
||||
}
|
||||
configPath := filepath.Join(contract.agentDirectory, "config.json")
|
||||
keyPath := filepath.Join(contract.agentDirectory, "private.key")
|
||||
config, configExists, err := readClineRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !configExists || key == nil || guard == nil || audit == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
||||
}
|
||||
expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, ""))
|
||||
expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true))
|
||||
return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil
|
||||
}
|
||||
@@ -17,37 +17,72 @@ func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) {
|
||||
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 {
|
||||
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 TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) {
|
||||
func TestClineInstallWritesAllSixManagedFilesAtomicallyAndIsIdempotent(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
||||
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)
|
||||
|
||||
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())
|
||||
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"))
|
||||
@@ -67,6 +102,13 @@ func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) {
|
||||
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 {
|
||||
@@ -123,7 +165,32 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
||||
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"}}`)
|
||||
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)
|
||||
@@ -135,12 +202,7 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
||||
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) {
|
||||
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"}) {
|
||||
@@ -152,10 +214,11 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
|
||||
passing := prepareClineFixture(t, clineFixtureOptions{
|
||||
GuardSource: clineString("process.stdin.resume();\n"),
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -8,17 +8,20 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const clineTestAgentID = "agent-1"
|
||||
const (
|
||||
clineTestAgentID = "agent-1"
|
||||
clinePrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
|
||||
)
|
||||
|
||||
type clineFixtureOptions struct {
|
||||
ExistingAudit *string
|
||||
ExistingGuard *string
|
||||
GuardSource *string
|
||||
SkipGuard bool
|
||||
}
|
||||
|
||||
type clineFixture struct {
|
||||
@@ -60,20 +63,11 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
|
||||
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
|
||||
auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs")
|
||||
|
||||
for _, directory := range []string{workspaceDir, agentDir} {
|
||||
for _, directory := range []string{workspaceDir} {
|
||||
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)
|
||||
|
||||
@@ -97,8 +91,9 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
|
||||
AgentName: "cline",
|
||||
OrgID: "org-1",
|
||||
AgentID: clineTestAgentID,
|
||||
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
|
||||
PrivateKey: clinePrivateKey,
|
||||
KID: "kid-1",
|
||||
Token: "token-1",
|
||||
BaseURL: "https://api.elydora.test",
|
||||
GuardScriptPath: guardPath,
|
||||
},
|
||||
@@ -193,13 +188,97 @@ func requireClineTestObject(t *testing.T, value any) map[string]any {
|
||||
|
||||
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 {
|
||||
if err := json.Unmarshal([]byte(stdout), &control); err != nil {
|
||||
t.Fatalf("decode Cline control output: %v", err)
|
||||
}
|
||||
return control
|
||||
}
|
||||
|
||||
func writeClineTestFile(
|
||||
t *testing.T,
|
||||
path string,
|
||||
contents []byte,
|
||||
mode os.FileMode,
|
||||
) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create parent directory for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, mode); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeClineTestObject(t *testing.T, path string, value map[string]any) {
|
||||
t.Helper()
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Cline test object: %v", err)
|
||||
}
|
||||
writeClineTestFile(t, path, append(encoded, '\n'), 0600)
|
||||
}
|
||||
|
||||
func readClineTestObject(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal([]byte(readClineTestFile(t, path)), &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func assertNoClineRuntimeWrites(t *testing.T, fixture *clineFixture) {
|
||||
t.Helper()
|
||||
for _, path := range []string{
|
||||
fixture.guardPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.hookPath,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("managed file exists at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoClineTransactionArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".rollback") {
|
||||
t.Errorf("transaction artifact remains at %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk Cline fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clineSymlinkOrSkip(t *testing.T, target, link string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symbolic links unavailable: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symbolic links unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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,368 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func prepareClineTestChanges(
|
||||
t *testing.T,
|
||||
fixture *clineFixture,
|
||||
) ([]*fileChange, *clineRuntimePaths) {
|
||||
t.Helper()
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
t.Fatalf("read Cline hook pair: %v", err)
|
||||
}
|
||||
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Cline installation: %v", err)
|
||||
}
|
||||
changes, err := prepareClineInstallationChanges(
|
||||
fixture.config,
|
||||
runtimePaths,
|
||||
guard,
|
||||
audit,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare Cline installation: %v", err)
|
||||
}
|
||||
return changes, runtimePaths
|
||||
}
|
||||
|
||||
func snapshotClineFiles(t *testing.T, paths ...string) map[string]string {
|
||||
t.Helper()
|
||||
result := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
result[path] = readClineTestFile(t, path)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func requireClineSnapshot(t *testing.T, snapshot map[string]string) {
|
||||
t.Helper()
|
||||
for path, expected := range snapshot {
|
||||
if actual := readClineTestFile(t, path); actual != expected {
|
||||
t.Fatalf("snapshot changed at %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallRollsBackAllSixFiles(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
failed = true
|
||||
return errors.New("injected Cline audit hook failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Cline audit hook failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
if !failed {
|
||||
t.Fatal("audit hook failure was not injected")
|
||||
}
|
||||
assertNoClineRuntimeWrites(t, fixture)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallRestoresAllSixExistingFilesAfterCommitFailure(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
|
||||
"org_id": "old-org", "agent_id": clineTestAgentID, "kid": "old-kid",
|
||||
"base_url": "https://old.elydora.test", "agent_name": clineAgentKey,
|
||||
})
|
||||
for path, source := range map[string]string{
|
||||
fixture.guardPath: "stale guard\n",
|
||||
fixture.privateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
|
||||
fixture.hookPath: "stale audit\n",
|
||||
fixture.guardWrapper: readClineTestFile(t, fixture.guardWrapper) + "// stale\n",
|
||||
fixture.auditWrapper: readClineTestFile(t, fixture.auditWrapper) + "// stale\n",
|
||||
} {
|
||||
writeClineTestFile(t, path, []byte(source), 0600)
|
||||
}
|
||||
paths := []string{
|
||||
fixture.guardPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.hookPath,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
}
|
||||
before := snapshotClineFiles(t, paths...)
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
failed = true
|
||||
return errors.New("injected existing Cline audit hook failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected existing Cline audit hook failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestPreparedClineInstallRejectsStaleHookBeforeStaging(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
t.Fatalf("read Cline hook pair: %v", err)
|
||||
}
|
||||
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Cline installation: %v", err)
|
||||
}
|
||||
concurrent := []byte("// concurrent user hook\n")
|
||||
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
|
||||
_, err = prepareClineInstallationChanges(
|
||||
fixture.config,
|
||||
runtimePaths,
|
||||
guard,
|
||||
audit,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed before update") {
|
||||
t.Fatalf("stale hook error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
|
||||
t.Fatal("concurrent hook changed")
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedClineInstallRejectsConcurrentHookChange(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
changes, paths := prepareClineTestChanges(t, fixture)
|
||||
concurrent := []byte("// concurrent user hook\n")
|
||||
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
|
||||
err := writeClineChanges(changes, "Install Cline hooks", nil, paths)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent hook error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
|
||||
t.Fatal("concurrent hook changed")
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallDetectsConcurrentHookIdentityReplacement(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
tampered := readClineTestFile(t, fixture.auditWrapper) + "// stale\n"
|
||||
writeClineTestFile(t, fixture.auditWrapper, []byte(tampered), 0700)
|
||||
fixture.config.OrgID = "org-updated"
|
||||
before := snapshotClineFiles(
|
||||
t,
|
||||
fixture.guardPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.hookPath,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
)
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||
mutated = true
|
||||
external := fixture.auditWrapper + ".external"
|
||||
if err := os.Rename(fixture.auditWrapper, external); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fixture.auditWrapper, []byte(tampered), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent identity error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineOrphanRuntimeArtifactsFailBeforeHookWrites(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"private.key", clineGuardScript, clineAuditScript,
|
||||
"chain-state.json", "status-cache.json", "error.log",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
artifact := filepath.Join(fixture.agentDir, name)
|
||||
writeClineTestFile(t, artifact, []byte("orphan\n"), 0600)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
|
||||
t.Fatalf("orphan runtime error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, artifact) != "orphan\n" {
|
||||
t.Fatal("orphan artifact changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
|
||||
"agent_id": "another-agent", "agent_name": clineAgentKey,
|
||||
})
|
||||
original := readClineTestFile(t, fixture.runtimeConfig)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("identity error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.runtimeConfig) != original {
|
||||
t.Fatal("runtime config changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
}
|
||||
|
||||
func TestClineLinkedDirectoriesAndFilesAreRejected(t *testing.T) {
|
||||
for _, kind := range []string{
|
||||
"configuration", "hooks", "runtime", "agent-directory", "runtime-config", "hook",
|
||||
} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
target := filepath.Join(t.TempDir(), kind+"-target")
|
||||
switch kind {
|
||||
case "configuration":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.clineDir)
|
||||
case "hooks":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(fixture.clineDir, 0700); err != nil {
|
||||
t.Fatalf("create Cline directory: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.hooksDir)
|
||||
case "runtime":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir))
|
||||
case "agent-directory":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil {
|
||||
t.Fatalf("create runtime root: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.agentDir)
|
||||
case "runtime-config":
|
||||
writeClineTestFile(t, target, []byte(`{"agent_id":"agent-1","agent_name":"cline"}`), 0600)
|
||||
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||
t.Fatalf("create agent directory: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.runtimeConfig)
|
||||
case "hook":
|
||||
installClineFixture(t, fixture)
|
||||
writeClineTestFile(t, target, []byte("external hook\n"), 0600)
|
||||
if err := os.Remove(fixture.guardWrapper); err != nil {
|
||||
t.Fatalf("remove guard wrapper: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.guardWrapper)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical") {
|
||||
t.Fatalf("linked %s error = %v", kind, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallValidatesInputsBeforeWrites(t *testing.T) {
|
||||
tests := []struct{ name, field, value, want string }{
|
||||
{"agent-name", "agent_name", "codex", "requires agent name cline"},
|
||||
{"agent-id", "agent_id", "../escape", "invalid agent ID"},
|
||||
{"organization", "org_id", " ", "organization ID is required"},
|
||||
{"key-id", "kid", " ", "key ID is required"},
|
||||
{"private-key", "private_key", "invalid", "canonical 32-byte"},
|
||||
{"token", "token", " ", "non-whitespace"},
|
||||
{"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"},
|
||||
{"guard-path", "guard_script_path", "outside", "managed agent directory"},
|
||||
{"audit-path", "hook_script", "outside", "managed agent directory"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
switch testCase.field {
|
||||
case "agent_name":
|
||||
fixture.config.AgentName = testCase.value
|
||||
case "agent_id":
|
||||
fixture.config.AgentID = testCase.value
|
||||
case "org_id":
|
||||
fixture.config.OrgID = testCase.value
|
||||
case "kid":
|
||||
fixture.config.KID = testCase.value
|
||||
case "private_key":
|
||||
fixture.config.PrivateKey = testCase.value
|
||||
case "token":
|
||||
fixture.config.Token = testCase.value
|
||||
case "base_url":
|
||||
fixture.config.BaseURL = testCase.value
|
||||
case "guard_script_path":
|
||||
fixture.config.GuardScriptPath = testCase.value
|
||||
case "hook_script":
|
||||
fixture.config.HookScript = testCase.value
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
assertNoClineRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRestoresBothHooksWhenSecondRemovalFails(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
before := snapshotClineFiles(t, fixture.guardWrapper, fixture.auditWrapper)
|
||||
calls := 0
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("injected Cline uninstall failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Uninstall(clineTestAgentID)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Cline uninstall failure") {
|
||||
t.Fatalf("uninstall error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallLeavesNoTransactionArtifacts(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
Reference in new issue
Block a user