fix(cline): mirror transactional Go adapter
This commit is contained in:
1 parent
052116081a
commit
d0185887fa
14 files changed
+1672
-475
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 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`.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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`.
|
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"
|
import "fmt"
|
||||||
|
|
||||||
// ClinePlugin manages Cline's native global file hooks.
|
// 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 {
|
func (p *ClinePlugin) Install(config InstallConfig) error {
|
||||||
if config.AgentID == "" {
|
paths, guard, audit, err := readClineHookPair()
|
||||||
return fmt.Errorf("agent ID is required")
|
|
||||||
}
|
|
||||||
paths, err := resolveClineHookFiles()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditState, err := readClineHookFile(paths.auditPath)
|
changes, err := prepareClineInstallationChanges(
|
||||||
|
config,
|
||||||
|
runtimePaths,
|
||||||
|
guard,
|
||||||
|
audit,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := requireAvailableClineHook(guardState); err != nil {
|
if err := writeClineChanges(
|
||||||
return err
|
changes,
|
||||||
}
|
"Install Cline hooks",
|
||||||
if err := requireAvailableClineHook(auditState); err != nil {
|
p.rename,
|
||||||
return err
|
runtimePaths,
|
||||||
}
|
|
||||||
if config.GuardScriptPath == "" {
|
|
||||||
return fmt.Errorf("guard script path is required")
|
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !guardExists {
|
|
||||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
|
||||||
}
|
|
||||||
hookPath, err := hookScriptPath(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if config.HookScript != "" {
|
|
||||||
hookPath = config.HookScript
|
|
||||||
}
|
|
||||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build Cline guard metadata: %w", err)
|
|
||||||
}
|
|
||||||
auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build Cline audit metadata: %w", err)
|
|
||||||
}
|
|
||||||
guardSource, err := buildClineWrapper(guardMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build Cline guard wrapper: %w", err)
|
|
||||||
}
|
|
||||||
auditSource, err := buildClineWrapper(auditMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build Cline audit wrapper: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := clineContractForFiles(
|
|
||||||
clineHookFile{
|
|
||||||
exists: true, filePath: paths.guardPath,
|
|
||||||
source: guardSource, metadata: &guardMetadata,
|
|
||||||
},
|
|
||||||
clineHookFile{
|
|
||||||
exists: true, filePath: paths.auditPath,
|
|
||||||
source: auditSource, metadata: &auditMetadata,
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
runtimeConfig := config
|
|
||||||
runtimeConfig.AgentName = clineAgentKey
|
|
||||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
|
||||||
}
|
|
||||||
if err := writeClineHookPair(
|
|
||||||
clinePendingWrite{state: guardState, source: guardSource},
|
|
||||||
clinePendingWrite{state: auditState, source: auditSource},
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -88,23 +66,27 @@ func (p *ClinePlugin) Install(config InstallConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ClinePlugin) Uninstall(agentID string) error {
|
func (p *ClinePlugin) Uninstall(agentID string) error {
|
||||||
paths, err := resolveClineHookFiles()
|
paths, guard, audit, err := readClineHookPair()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guardState, err := readClineHookFile(paths.guardPath)
|
changes, err := prepareClineUninstallChanges(
|
||||||
|
[]clineHookFile{guard, audit},
|
||||||
|
agentID,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditState, err := readClineHookFile(paths.auditPath)
|
return writeClineHookChanges(
|
||||||
if err != nil {
|
changes,
|
||||||
return err
|
"Uninstall Cline hooks",
|
||||||
}
|
p.rename,
|
||||||
return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID)
|
paths,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ClinePlugin) Status() (PluginStatus, error) {
|
func (p *ClinePlugin) Status() (PluginStatus, error) {
|
||||||
paths, err := resolveClineHookFiles()
|
paths, guard, audit, err := readClineHookPair()
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: clineAgentKey,
|
AgentName: clineAgentKey,
|
||||||
DisplayName: "Cline",
|
DisplayName: "Cline",
|
||||||
@@ -113,15 +95,7 @@ func (p *ClinePlugin) Status() (PluginStatus, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
guardState, err := readClineHookFile(paths.guardPath)
|
contract, err := clineContractForFiles(guard, audit)
|
||||||
if err != nil {
|
|
||||||
return status, err
|
|
||||||
}
|
|
||||||
auditState, err := readClineHookFile(paths.auditPath)
|
|
||||||
if err != nil {
|
|
||||||
return status, err
|
|
||||||
}
|
|
||||||
contract, err := clineContractForFiles(guardState, auditState)
|
|
||||||
if err != nil || contract == nil {
|
if err != nil || contract == nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type clineHookFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clineHookPaths struct {
|
type clineHookPaths struct {
|
||||||
|
clineDirectory string
|
||||||
hooksDirectory string
|
hooksDirectory string
|
||||||
guardPath string
|
guardPath string
|
||||||
auditPath string
|
auditPath string
|
||||||
@@ -55,14 +56,6 @@ func clineHomeDirectory() (string, error) {
|
|||||||
return home, nil
|
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) {
|
func resolveClineHookFiles() (clineHookPaths, error) {
|
||||||
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
|
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
|
||||||
if clineDirectory == "" {
|
if clineDirectory == "" {
|
||||||
@@ -80,6 +73,7 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
|||||||
}
|
}
|
||||||
hooksDirectory := filepath.Join(clineDirectory, "hooks")
|
hooksDirectory := filepath.Join(clineDirectory, "hooks")
|
||||||
return clineHookPaths{
|
return clineHookPaths{
|
||||||
|
clineDirectory: clineDirectory,
|
||||||
hooksDirectory: hooksDirectory,
|
hooksDirectory: hooksDirectory,
|
||||||
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
|
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
|
||||||
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
|
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
|
||||||
@@ -225,7 +219,7 @@ async function main() {
|
|||||||
|
|
||||||
if (hookKind === 'guard' && result.code === 2) {
|
if (hookKind === 'guard' && result.code === 2) {
|
||||||
const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
|
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
|
||||||
@@ -303,13 +297,6 @@ func assertClineWrapperIntegrity(file clineHookFile) error {
|
|||||||
return nil
|
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(
|
func clineContractForFiles(
|
||||||
guardFile, auditFile clineHookFile,
|
guardFile, auditFile clineHookFile,
|
||||||
) (*clineRuntimeContract, error) {
|
) (*clineRuntimeContract, error) {
|
||||||
@@ -330,14 +317,10 @@ func clineContractForFiles(
|
|||||||
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
|
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
|
||||||
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
||||||
}
|
}
|
||||||
if err := validateClineAgentSegment(guard.AgentID); err != nil {
|
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := clineElydoraRoot()
|
|
||||||
if err != nil {
|
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)
|
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
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
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"errors"
|
|
||||||
"fmt"
|
func validateClineHookDirectories(paths clineHookPaths) error {
|
||||||
"io"
|
if _, err := managedPhysicalDirectoryExists(
|
||||||
"os"
|
paths.clineDirectory,
|
||||||
"path/filepath"
|
"Cline configuration directory",
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := managedPhysicalDirectoryExists(
|
||||||
|
paths.hooksDirectory,
|
||||||
|
"Cline hooks directory",
|
||||||
)
|
)
|
||||||
|
return err
|
||||||
type clinePendingWrite struct {
|
|
||||||
state clineHookFile
|
|
||||||
source string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type clineStagedFile struct {
|
|
||||||
state clineHookFile
|
|
||||||
temporaryPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type clineRenameFunc func(source, destination string) error
|
|
||||||
|
|
||||||
func readClineHookFile(filePath string) (clineHookFile, error) {
|
func readClineHookFile(filePath string) (clineHookFile, error) {
|
||||||
// #nosec G304 -- filePath is resolved from Cline's native hook directory.
|
snapshot, err := readManagedFile(filePath, "Cline hook", maxManagedSourceBytes)
|
||||||
raw, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return clineHookFile{}, err
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
return clineHookFile{filePath: filePath}, nil
|
return clineHookFile{filePath: filePath}, nil
|
||||||
}
|
}
|
||||||
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err)
|
source := string(snapshot.contents)
|
||||||
}
|
|
||||||
source := string(raw)
|
|
||||||
metadata, err := parseClineMetadata(filePath, source)
|
metadata, err := parseClineMetadata(filePath, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return clineHookFile{}, err
|
return clineHookFile{}, err
|
||||||
}
|
}
|
||||||
return clineHookFile{
|
return clineHookFile{
|
||||||
exists: true,
|
exists: true, filePath: filePath, source: source, metadata: metadata,
|
||||||
filePath: filePath,
|
|
||||||
source: source,
|
|
||||||
metadata: metadata,
|
|
||||||
}, nil
|
}, 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 {
|
func requireAvailableClineHook(file clineHookFile) error {
|
||||||
if file.exists && file.metadata == nil {
|
if file.exists && file.metadata == nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
@@ -52,160 +68,80 @@ func requireAvailableClineHook(file clineHookFile) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeClineTemporary(path string) error {
|
func writeClineHookChanges(
|
||||||
if path == "" {
|
changes []*fileChange,
|
||||||
return nil
|
label string,
|
||||||
}
|
rename renameFunc,
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
paths clineHookPaths,
|
||||||
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func failClineStage(temporary *os.File, path string, cause error) error {
|
|
||||||
closeErr := temporary.Close()
|
|
||||||
removeErr := removeClineTemporary(path)
|
|
||||||
return errors.Join(cause, closeErr, removeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stageClineHook(write clinePendingWrite) (clineStagedFile, error) {
|
|
||||||
directory := filepath.Dir(write.state.filePath)
|
|
||||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
|
||||||
return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err)
|
|
||||||
}
|
|
||||||
temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err)
|
|
||||||
}
|
|
||||||
temporaryPath := temporary.Name()
|
|
||||||
if err := temporary.Chmod(0700); err != nil {
|
|
||||||
return clineStagedFile{}, failClineStage(
|
|
||||||
temporary,
|
|
||||||
temporaryPath,
|
|
||||||
fmt.Errorf("set permissions for %s: %w", temporaryPath, err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
written, err := temporary.WriteString(write.source)
|
|
||||||
if err != nil {
|
|
||||||
return clineStagedFile{}, failClineStage(
|
|
||||||
temporary,
|
|
||||||
temporaryPath,
|
|
||||||
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if written != len(write.source) {
|
|
||||||
return clineStagedFile{}, failClineStage(
|
|
||||||
temporary,
|
|
||||||
temporaryPath,
|
|
||||||
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err := temporary.Sync(); err != nil {
|
|
||||||
return clineStagedFile{}, failClineStage(
|
|
||||||
temporary,
|
|
||||||
temporaryPath,
|
|
||||||
fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err := temporary.Close(); err != nil {
|
|
||||||
return clineStagedFile{}, errors.Join(
|
|
||||||
fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err),
|
|
||||||
removeClineTemporary(temporaryPath),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rollbackClineHook(state clineHookFile) error {
|
|
||||||
if state.exists {
|
|
||||||
return writeHookFileAtomic(state.filePath, []byte(state.source), 0700)
|
|
||||||
}
|
|
||||||
return removeHookFile(state.filePath, "Cline hook rollback")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeClineHookPair(guard, audit clinePendingWrite) error {
|
|
||||||
return writeClineHookPairWithRename(guard, audit, os.Rename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeClineHookPairWithRename(
|
|
||||||
guard, audit clinePendingWrite,
|
|
||||||
rename clineRenameFunc,
|
|
||||||
) error {
|
) error {
|
||||||
writes := []clinePendingWrite{guard, audit}
|
if !hasClineChanges(changes) {
|
||||||
staged := make([]clineStagedFile, 0, len(writes))
|
|
||||||
for _, write := range writes {
|
|
||||||
file, err := stageClineHook(write)
|
|
||||||
if err != nil {
|
|
||||||
failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)}
|
|
||||||
for _, item := range staged {
|
|
||||||
if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil {
|
|
||||||
failures = append(failures, cleanupErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(failures...)
|
|
||||||
}
|
|
||||||
staged = append(staged, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
committed := 0
|
|
||||||
for index, item := range staged {
|
|
||||||
if err := rename(item.temporaryPath, item.state.filePath); err != nil {
|
|
||||||
failures := []error{
|
|
||||||
fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err),
|
|
||||||
}
|
|
||||||
for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- {
|
|
||||||
if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil {
|
|
||||||
failures = append(failures, fmt.Errorf(
|
|
||||||
"restore Cline hook at %s: %w",
|
|
||||||
staged[rollbackIndex].state.filePath,
|
|
||||||
rollbackErr,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ {
|
|
||||||
if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil {
|
|
||||||
failures = append(failures, cleanupErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(failures...)
|
|
||||||
}
|
|
||||||
committed++
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := ensureManagedDirectory(
|
||||||
func removeOwnedClineHooks(files []clineHookFile, agentID string) error {
|
paths.clineDirectory,
|
||||||
for _, file := range files {
|
"Cline configuration directory",
|
||||||
if file.metadata == nil {
|
); err != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureManagedDirectory(paths.hooksDirectory, "Cline hooks directory"); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return writeChanges(changes, label, rename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
func writeClineChanges(
|
||||||
configPath := filepath.Join(contract.agentDirectory, "config.json")
|
changes []*fileChange,
|
||||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
label string,
|
||||||
if err != nil || !exists {
|
rename renameFunc,
|
||||||
return false, err
|
paths *clineRuntimePaths,
|
||||||
|
) error {
|
||||||
|
if !hasClineChanges(changes) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
agentID, ok := config["agent_id"].(string)
|
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
|
||||||
if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) {
|
return err
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
|
if err := EnsurePrivateDirectory(paths.agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeClineHookChanges(changes, label, rename, paths.hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ||
|
||||||
|
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := assertClineWrapperIntegrity(file); err != nil {
|
||||||
|
return nil, 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 {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
|
changes = append(changes, change)
|
||||||
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"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) {
|
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{})
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
installClineFixture(t, fixture)
|
installClineFixture(t, fixture)
|
||||||
status, err := fixture.plugin.Status()
|
status, err := fixture.plugin.Status()
|
||||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||||
t.Fatalf("installed status = %#v, %v", status, err)
|
t.Fatalf("installed status = %#v, %v", status, err)
|
||||||
}
|
}
|
||||||
if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir {
|
paths := map[string]string{
|
||||||
t.Fatalf("status metadata = %#v", status)
|
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||||
|
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||||
|
"guard-wrapper": fixture.guardWrapper,
|
||||||
|
"audit-wrapper": fixture.auditWrapper,
|
||||||
}
|
}
|
||||||
if err := os.Remove(fixture.guardPath); err != nil {
|
if err := os.Remove(paths[target]); err != nil {
|
||||||
t.Fatalf("remove guard runtime: %v", err)
|
t.Fatalf("remove %s: %v", target, err)
|
||||||
}
|
}
|
||||||
status, err = fixture.plugin.Status()
|
status, err = fixture.plugin.Status()
|
||||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
if err != nil || status.Installed || status.HookScriptExists {
|
||||||
t.Fatalf("degraded status = %#v, %v", status, err)
|
t.Fatalf("missing %s status = %#v, %v", target, status, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil {
|
if strings.Contains(target, "wrapper") && status.HookConfigured {
|
||||||
t.Fatalf("restore guard runtime: %v", err)
|
t.Fatalf("missing wrapper remains configured: %#v", status)
|
||||||
}
|
|
||||||
if err := os.Remove(fixture.auditWrapper); err != nil {
|
|
||||||
t.Fatalf("remove audit wrapper: %v", err)
|
|
||||||
}
|
|
||||||
status, err = fixture.plugin.Status()
|
|
||||||
if err != nil || status.Installed || status.HookConfigured {
|
|
||||||
t.Fatalf("partial status = %#v, %v", status, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) {
|
|
||||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
|
||||||
installClineFixture(t, fixture)
|
|
||||||
guard := readClineTestFile(t, fixture.guardWrapper)
|
|
||||||
if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil {
|
|
||||||
t.Fatalf("tamper guard wrapper: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") {
|
|
||||||
t.Fatalf("tampered status error = %v", err)
|
|
||||||
}
|
|
||||||
installClineFixture(t, fixture)
|
|
||||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
|
||||||
t.Fatalf("corrupt runtime config: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
|
||||||
t.Fatalf("runtime status error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) {
|
|
||||||
for _, collision := range []string{"guard", "audit"} {
|
|
||||||
t.Run(collision, func(t *testing.T) {
|
|
||||||
options := clineFixtureOptions{}
|
|
||||||
if collision == "guard" {
|
|
||||||
options.ExistingGuard = clineString("// user PreToolUse hook\n")
|
|
||||||
} else {
|
|
||||||
options.ExistingAudit = clineString("// user PostToolUse hook\n")
|
|
||||||
}
|
|
||||||
fixture := prepareClineFixture(t, options)
|
|
||||||
err := fixture.plugin.Install(fixture.config)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "owned by another integration") {
|
|
||||||
t.Fatalf("install error = %v", err)
|
|
||||||
}
|
|
||||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
|
||||||
requireMissingClineTestFile(t, path)
|
|
||||||
}
|
|
||||||
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
|
|
||||||
t.Fatalf("user guard wrapper changed")
|
|
||||||
}
|
|
||||||
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
|
|
||||||
t.Fatalf("user audit wrapper changed")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) {
|
func TestClineStatusSurfacesTamperingAndInvalidRuntimeMetadata(t *testing.T) {
|
||||||
corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n"
|
tests := []struct {
|
||||||
fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt})
|
name, target, source, want string
|
||||||
err := fixture.plugin.Install(fixture.config)
|
}{
|
||||||
if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") {
|
{"guard-source", "guard", "tampered\n", ""},
|
||||||
t.Fatalf("install error = %v", err)
|
{"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 {
|
for _, testCase := range tests {
|
||||||
t.Fatalf("corrupt hook changed")
|
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) {
|
func TestClineStatusRejectsInvalidHookContracts(t *testing.T) {
|
||||||
fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true})
|
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"},
|
||||||
|
} {
|
||||||
|
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 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)
|
err := fixture.plugin.Install(fixture.config)
|
||||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
t.Fatalf("install error = %v", err)
|
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{
|
for _, path := range []string{
|
||||||
fixture.guardWrapper,
|
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||||
fixture.auditWrapper,
|
|
||||||
fixture.hookPath,
|
|
||||||
fixture.runtimeConfig,
|
|
||||||
fixture.privateKey,
|
|
||||||
} {
|
} {
|
||||||
requireMissingClineTestFile(t, path)
|
requireMissingClineTestFile(t, path)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) {
|
func TestClineUninstallRemovesExactOwnershipAndPreservesAdjacentHooks(t *testing.T) {
|
||||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
installClineFixture(t, fixture)
|
installClineFixture(t, fixture)
|
||||||
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
|
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
|
||||||
if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil {
|
writeClineTestFile(t, userHook, []byte("# user hook\n"), 0600)
|
||||||
t.Fatalf("write user hook: %v", err)
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
||||||
t.Fatalf("uninstall other agent: %v", err)
|
t.Fatalf("uninstall other agent: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,78 +184,21 @@ func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T)
|
|||||||
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
|
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
|
||||||
t.Fatalf("uninstall Cline hooks: %v", err)
|
t.Fatalf("uninstall Cline hooks: %v", err)
|
||||||
}
|
}
|
||||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
for _, path := range []string{fixture.guardWrapper, fixture.auditWrapper} {
|
||||||
requireMissingClineTestFile(t, 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" {
|
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 := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
|
fixture.config.Token = ""
|
||||||
installClineFixture(t, fixture)
|
installClineFixture(t, fixture)
|
||||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
if _, exists := readClineTestObject(t, fixture.runtimeConfig)["token"]; exists {
|
||||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
t.Fatal("empty token persisted")
|
||||||
guardState, err := readClineHookFile(fixture.guardWrapper)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read guard state: %v", err)
|
|
||||||
}
|
|
||||||
auditState, err := readClineHookFile(fixture.auditWrapper)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read audit state: %v", err)
|
|
||||||
}
|
|
||||||
guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build replacement guard metadata: %v", err)
|
|
||||||
}
|
|
||||||
auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build replacement audit metadata: %v", err)
|
|
||||||
}
|
|
||||||
replacementGuard, err := buildClineWrapper(guardMetadata)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build replacement guard: %v", err)
|
|
||||||
}
|
|
||||||
replacementAudit, err := buildClineWrapper(auditMetadata)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build replacement audit: %v", err)
|
|
||||||
}
|
|
||||||
failureInjected := false
|
|
||||||
rename := func(source, destination string) error {
|
|
||||||
if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) {
|
|
||||||
failureInjected = true
|
|
||||||
return errors.New("simulated audit commit failure")
|
|
||||||
}
|
|
||||||
return os.Rename(source, destination)
|
|
||||||
}
|
|
||||||
err = writeClineHookPairWithRename(
|
|
||||||
clinePendingWrite{state: guardState, source: replacementGuard},
|
|
||||||
clinePendingWrite{state: auditState, source: replacementAudit},
|
|
||||||
rename,
|
|
||||||
)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") {
|
|
||||||
t.Fatalf("write pair error = %v", err)
|
|
||||||
}
|
|
||||||
if !failureInjected {
|
|
||||||
t.Fatalf("audit commit failure was not injected")
|
|
||||||
}
|
|
||||||
if readClineTestFile(t, fixture.guardWrapper) != originalGuard ||
|
|
||||||
readClineTestFile(t, fixture.auditWrapper) != originalAudit {
|
|
||||||
t.Fatalf("hook pair was not restored")
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(fixture.hooksDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read hook directory: %v", err)
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
|
||||||
t.Fatalf("temporary hook remains: %s", entry.Name())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func sameClineTestPath(left, right string) bool {
|
|
||||||
leftPath, leftErr := filepath.Abs(left)
|
|
||||||
rightPath, rightErr := filepath.Abs(right)
|
|
||||||
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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" {
|
if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" {
|
||||||
t.Fatalf("Cline registry entry = %#v", entry)
|
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")
|
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{})
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
installClineFixture(t, fixture)
|
installClineFixture(t, fixture)
|
||||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
files := []string{
|
||||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
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)
|
installClineFixture(t, fixture)
|
||||||
|
|
||||||
if readClineTestFile(t, fixture.guardWrapper) != originalGuard {
|
for path, source := range original {
|
||||||
t.Fatalf("guard wrapper changed during idempotent install")
|
if readClineTestFile(t, path) != source {
|
||||||
}
|
t.Fatalf("%s changed during idempotent install", path)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs"))
|
||||||
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
|
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
|
||||||
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "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) {
|
func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) {
|
||||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
installClineFixture(t, fixture)
|
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")
|
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||||
captureJSON, err := json.Marshal(capturePath)
|
captureJSON, err := json.Marshal(capturePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,7 +165,32 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
|||||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
fixture.config.BaseURL = server.URL
|
fixture.config.BaseURL = server.URL
|
||||||
installClineFixture(t, fixture)
|
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)
|
result := runClineWrapper(t, fixture, fixture.auditWrapper, payload)
|
||||||
if result.exitCode != 0 {
|
if result.exitCode != 0 {
|
||||||
t.Fatalf("audit wrapper result = %#v", result)
|
t.Fatalf("audit wrapper result = %#v", result)
|
||||||
@@ -135,12 +202,7 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
|
|||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
t.Fatal("timed out waiting for Elydora operation")
|
t.Fatal("timed out waiting for Elydora operation")
|
||||||
}
|
}
|
||||||
wantPayload := map[string]any{
|
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), payloadObject) {
|
||||||
"tool_name": "read_file",
|
|
||||||
"tool_input": map[string]any{"path": "README.md"},
|
|
||||||
"session_id": "task-1",
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) {
|
|
||||||
t.Fatalf("operation payload = %#v", operation["payload"])
|
t.Fatalf("operation payload = %#v", operation["payload"])
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) {
|
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) {
|
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
|
||||||
passing := prepareClineFixture(t, clineFixtureOptions{
|
passing := prepareClineFixture(t, clineFixtureOptions{})
|
||||||
GuardSource: clineString("process.stdin.resume();\n"),
|
|
||||||
})
|
|
||||||
installClineFixture(t, passing)
|
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(`{}`))
|
passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`))
|
||||||
if passResult.exitCode != 0 || passResult.stdout != "" {
|
if passResult.exitCode != 0 || passResult.stdout != "" {
|
||||||
t.Fatalf("passing guard result = %#v", passResult)
|
t.Fatalf("passing guard result = %#v", passResult)
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const clineTestAgentID = "agent-1"
|
const (
|
||||||
|
clineTestAgentID = "agent-1"
|
||||||
|
clinePrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
|
||||||
|
)
|
||||||
|
|
||||||
type clineFixtureOptions struct {
|
type clineFixtureOptions struct {
|
||||||
ExistingAudit *string
|
ExistingAudit *string
|
||||||
ExistingGuard *string
|
ExistingGuard *string
|
||||||
GuardSource *string
|
|
||||||
SkipGuard bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type clineFixture struct {
|
type clineFixture struct {
|
||||||
@@ -60,20 +63,11 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
|
|||||||
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
|
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
|
||||||
auditWrapper := filepath.Join(hooksDir, "PostToolUse.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 {
|
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||||
t.Fatalf("create fixture directory %s: %v", directory, err)
|
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, guardWrapper, options.ExistingGuard)
|
||||||
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
|
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
|
||||||
|
|
||||||
@@ -97,8 +91,9 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
|
|||||||
AgentName: "cline",
|
AgentName: "cline",
|
||||||
OrgID: "org-1",
|
OrgID: "org-1",
|
||||||
AgentID: clineTestAgentID,
|
AgentID: clineTestAgentID,
|
||||||
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
|
PrivateKey: clinePrivateKey,
|
||||||
KID: "kid-1",
|
KID: "kid-1",
|
||||||
|
Token: "token-1",
|
||||||
BaseURL: "https://api.elydora.test",
|
BaseURL: "https://api.elydora.test",
|
||||||
GuardScriptPath: guardPath,
|
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 {
|
func decodeClineControl(t *testing.T, stdout string) map[string]any {
|
||||||
t.Helper()
|
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
|
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)
|
t.Fatalf("decode Cline control output: %v", err)
|
||||||
}
|
}
|
||||||
return control
|
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