From d0185887fa2322848fd1baacb64be881868a07cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 11:39:06 -0400 Subject: [PATCH] fix(cline): mirror transactional Go adapter --- AGENTS.md | 2 +- sdks/go/README.md | 2 +- sdks/go/cmd/elydora/cline_cli_test.go | 194 +++++++++ sdks/go/cmd/elydora/plugins/cline.go | 140 +++---- sdks/go/cmd/elydora/plugins/cline_contract.go | 27 +- .../cmd/elydora/plugins/cline_installation.go | 219 +++++++++++ sdks/go/cmd/elydora/plugins/cline_io.go | 272 +++++-------- .../elydora/plugins/cline_official_test.go | 81 ++++ .../elydora/plugins/cline_recovery_test.go | 318 ++++++++------- .../cmd/elydora/plugins/cline_runtime_test.go | 96 +++++ sdks/go/cmd/elydora/plugins/cline_status.go | 206 ++++++++++ sdks/go/cmd/elydora/plugins/cline_test.go | 123 ++++-- .../plugins/cline_test_helpers_test.go | 117 +++++- .../elydora/plugins/cline_transaction_test.go | 368 ++++++++++++++++++ 14 files changed, 1681 insertions(+), 484 deletions(-) create mode 100644 sdks/go/cmd/elydora/cline_cli_test.go create mode 100644 sdks/go/cmd/elydora/plugins/cline_installation.go create mode 100644 sdks/go/cmd/elydora/plugins/cline_official_test.go create mode 100644 sdks/go/cmd/elydora/plugins/cline_runtime_test.go create mode 100644 sdks/go/cmd/elydora/plugins/cline_status.go create mode 100644 sdks/go/cmd/elydora/plugins/cline_transaction_test.go diff --git a/AGENTS.md b/AGENTS.md index 0ffa768..d5e82e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ This repository publishes the open-source server, console, integration catalog, - Resolve Gemini CLI user hooks through `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` when the variable is absent or empty. Preserve relative and literal-tilde home values as Gemini CLI does. Keep workspace, system defaults, system overrides, and extension hooks read-only. Preserve JSON comments while rejecting trailing commas and duplicate keys. Register exact matchless `BeforeTool` and `AfterTool` groups named `elydora-guard` and `elydora-audit` with ten-second command handlers. Preserve the complete native snake_case payload, emit valid JSON on exit code `0`, and propagate freeze and revocation through exit code `2`. Use encoded PowerShell commands on Windows. Commit each SDK's Gemini CLI user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction. Respect `hooksConfig.enabled` and `hooksConfig.disabled`, then require `/hooks list` verification for higher-scope policy effects. - Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. - All three SDKs commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Their status checks require exact runtime identity, canonical private keys, physical files, and exact wrapper sources. Installation validates matcher syntax with JavaScript `RegExp`; status and uninstall stay available for offline recovery. Verify the effective configuration with `auggie tools list`. -- Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux. +- Treat Cline 3 CLI file hooks as the SDK adapter contract. Resolve hooks additively from `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, `.clinerules/hooks`, and `.cline/hooks`; write only the `$CLINE_DIR` source. Preserve the complete hybrid CLI payload and use supported event filename extensions. Commit both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Status requires physical files, canonical private keys, exact runtime identity, and exact generated runtime and wrapper sources. Node and Go wrappers use `process.execPath`; Python wrappers resolve the runtime executable from an absolute shebang. Translate guard exit code `2` into one-line JSON stdout with `cancel: true`; hook errors, timeouts, and invalid control JSON fail open. Model Cline IDE as a separate variant with runtime `--hooks-dir`, Documents, and `.clinerules/hooks` roots, exact `.ps1` filenames on Windows, and executable extensionless filenames on macOS and Linux. - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default, matching Codex's existing-directory canonicalization rule. Model user JSON, inline TOML layers, project files, managed `requirements.toml`, and plugin-bundled hooks as additive stable sources. SDK adapters write only the user JSON source, register exact `PreToolUse` and `PostToolUse` match-all command groups, preserve the complete native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, commit user hooks and all four runtime artifacts through one rollback-capable transaction, and require `/hooks` trust review for both definition hashes. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control. diff --git a/sdks/go/README.md b/sdks/go/README.md index a16c3d3..ab596e7 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -86,7 +86,7 @@ Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` comm GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false. -Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. +Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key. Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. diff --git a/sdks/go/cmd/elydora/cline_cli_test.go b/sdks/go/cmd/elydora/cline_cli_test.go new file mode 100644 index 0000000..f5e9741 --- /dev/null +++ b/sdks/go/cmd/elydora/cline_cli_test.go @@ -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) + } + } +} diff --git a/sdks/go/cmd/elydora/plugins/cline.go b/sdks/go/cmd/elydora/plugins/cline.go index 7691bff..0db5396 100644 --- a/sdks/go/cmd/elydora/plugins/cline.go +++ b/sdks/go/cmd/elydora/plugins/cline.go @@ -3,83 +3,61 @@ package plugins import "fmt" // ClinePlugin manages Cline's native global file hooks. -type ClinePlugin struct{} +type ClinePlugin struct { + rename renameFunc +} + +// ManagesGuardRuntime reports that Cline commits both generated runtimes and +// both native hook files in one transaction. +func (p *ClinePlugin) ManagesGuardRuntime() bool { + return true +} + +// PreflightInstall validates hook ownership and runtime identity before writes. +func (p *ClinePlugin) PreflightInstall(config InstallConfig) error { + paths, guard, audit, err := readClineHookPair() + if err != nil { + return err + } + if err := requireAvailableClineHook(guard); err != nil { + return err + } + if err := requireAvailableClineHook(audit); err != nil { + return err + } + _, err = preflightClineInstallation(config, paths) + return err +} func (p *ClinePlugin) Install(config InstallConfig) error { - if config.AgentID == "" { - return fmt.Errorf("agent ID is required") - } - paths, err := resolveClineHookFiles() + paths, guard, audit, err := readClineHookPair() if err != nil { return err } - guardState, err := readClineHookFile(paths.guardPath) + if err := requireAvailableClineHook(guard); err != nil { + return err + } + if err := requireAvailableClineHook(audit); err != nil { + return err + } + runtimePaths, err := preflightClineInstallation(config, paths) if err != nil { return err } - auditState, err := readClineHookFile(paths.auditPath) + changes, err := prepareClineInstallationChanges( + config, + runtimePaths, + guard, + audit, + ) if err != nil { return err } - if err := requireAvailableClineHook(guardState); err != nil { - return err - } - if err := requireAvailableClineHook(auditState); err != nil { - return err - } - if config.GuardScriptPath == "" { - return fmt.Errorf("guard script path is required") - } - guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") - if err != nil { - return err - } - if !guardExists { - return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) - } - hookPath, err := hookScriptPath(config.AgentID) - if err != nil { - return err - } - if config.HookScript != "" { - hookPath = config.HookScript - } - guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath) - if err != nil { - return fmt.Errorf("build Cline guard metadata: %w", err) - } - auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath) - if err != nil { - return fmt.Errorf("build Cline audit metadata: %w", err) - } - guardSource, err := buildClineWrapper(guardMetadata) - if err != nil { - return fmt.Errorf("build Cline guard wrapper: %w", err) - } - auditSource, err := buildClineWrapper(auditMetadata) - if err != nil { - return fmt.Errorf("build Cline audit wrapper: %w", err) - } - if _, err := clineContractForFiles( - clineHookFile{ - exists: true, filePath: paths.guardPath, - source: guardSource, metadata: &guardMetadata, - }, - clineHookFile{ - exists: true, filePath: paths.auditPath, - source: auditSource, metadata: &auditMetadata, - }, - ); err != nil { - return err - } - runtimeConfig := config - runtimeConfig.AgentName = clineAgentKey - if err := GenerateHookScript(hookPath, runtimeConfig); err != nil { - return fmt.Errorf("generate hook script: %w", err) - } - if err := writeClineHookPair( - clinePendingWrite{state: guardState, source: guardSource}, - clinePendingWrite{state: auditState, source: auditSource}, + if err := writeClineChanges( + changes, + "Install Cline hooks", + p.rename, + runtimePaths, ); err != nil { return err } @@ -88,23 +66,27 @@ func (p *ClinePlugin) Install(config InstallConfig) error { } func (p *ClinePlugin) Uninstall(agentID string) error { - paths, err := resolveClineHookFiles() + paths, guard, audit, err := readClineHookPair() if err != nil { return err } - guardState, err := readClineHookFile(paths.guardPath) + changes, err := prepareClineUninstallChanges( + []clineHookFile{guard, audit}, + agentID, + ) if err != nil { return err } - auditState, err := readClineHookFile(paths.auditPath) - if err != nil { - return err - } - return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID) + return writeClineHookChanges( + changes, + "Uninstall Cline hooks", + p.rename, + paths, + ) } func (p *ClinePlugin) Status() (PluginStatus, error) { - paths, err := resolveClineHookFiles() + paths, guard, audit, err := readClineHookPair() status := PluginStatus{ AgentName: clineAgentKey, DisplayName: "Cline", @@ -113,15 +95,7 @@ func (p *ClinePlugin) Status() (PluginStatus, error) { if err != nil { return status, err } - guardState, err := readClineHookFile(paths.guardPath) - if err != nil { - return status, err - } - auditState, err := readClineHookFile(paths.auditPath) - if err != nil { - return status, err - } - contract, err := clineContractForFiles(guardState, auditState) + contract, err := clineContractForFiles(guard, audit) if err != nil || contract == nil { return status, err } diff --git a/sdks/go/cmd/elydora/plugins/cline_contract.go b/sdks/go/cmd/elydora/plugins/cline_contract.go index 668ebd6..5d2f0d1 100644 --- a/sdks/go/cmd/elydora/plugins/cline_contract.go +++ b/sdks/go/cmd/elydora/plugins/cline_contract.go @@ -35,6 +35,7 @@ type clineHookFile struct { } type clineHookPaths struct { + clineDirectory string hooksDirectory string guardPath string auditPath string @@ -55,14 +56,6 @@ func clineHomeDirectory() (string, error) { return home, nil } -func clineElydoraRoot() (string, error) { - home, err := clineHomeDirectory() - if err != nil { - return "", err - } - return filepath.Join(home, ".elydora"), nil -} - func resolveClineHookFiles() (clineHookPaths, error) { clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR")) if clineDirectory == "" { @@ -80,6 +73,7 @@ func resolveClineHookFiles() (clineHookPaths, error) { } hooksDirectory := filepath.Join(clineDirectory, "hooks") return clineHookPaths{ + clineDirectory: clineDirectory, hooksDirectory: hooksDirectory, guardPath: filepath.Join(hooksDirectory, clineGuardFileName), auditPath: filepath.Join(hooksDirectory, clineAuditFileName), @@ -225,7 +219,7 @@ async function main() { if (hookKind === 'guard' && result.code === 2) { const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.'; - process.stdout.write('HOOK_CONTROL\t' + JSON.stringify({ cancel: true, errorMessage }) + '\n'); + process.stdout.write(JSON.stringify({ cancel: true, errorMessage }) + '\n'); return; } if (result.signal) throw new Error('runtime terminated by signal ' + result.signal); @@ -303,13 +297,6 @@ func assertClineWrapperIntegrity(file clineHookFile) error { return nil } -func validateClineAgentSegment(agentID string) error { - if agentID == "." || agentID == ".." || filepath.Base(agentID) != agentID { - return fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId") - } - return nil -} - func clineContractForFiles( guardFile, auditFile clineHookFile, ) (*clineRuntimeContract, error) { @@ -330,14 +317,10 @@ func clineContractForFiles( if !sameClineAgentID(guard.AgentID, audit.AgentID) { return nil, fmt.Errorf("Elydora Cline hook files reference different agents") } - if err := validateClineAgentSegment(guard.AgentID); err != nil { - return nil, err - } - runtimeRoot, err := clineElydoraRoot() + agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID) if err != nil { - return nil, err + return nil, fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId: %w", err) } - agentDirectory := filepath.Join(runtimeRoot, guard.AgentID) expectedGuard := filepath.Join(agentDirectory, clineGuardScript) expectedAudit := filepath.Join(agentDirectory, clineAuditScript) guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) diff --git a/sdks/go/cmd/elydora/plugins/cline_installation.go b/sdks/go/cmd/elydora/plugins/cline_installation.go new file mode 100644 index 0000000..dff29e8 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/cline_installation.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/cline_io.go b/sdks/go/cmd/elydora/plugins/cline_io.go index 06eb801..bc8d549 100644 --- a/sdks/go/cmd/elydora/plugins/cline_io.go +++ b/sdks/go/cmd/elydora/plugins/cline_io.go @@ -1,47 +1,63 @@ package plugins -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" -) +import "fmt" -type clinePendingWrite struct { - state clineHookFile - source string +func validateClineHookDirectories(paths clineHookPaths) error { + if _, err := managedPhysicalDirectoryExists( + paths.clineDirectory, + "Cline configuration directory", + ); err != nil { + return err + } + _, err := managedPhysicalDirectoryExists( + paths.hooksDirectory, + "Cline hooks directory", + ) + return err } -type clineStagedFile struct { - state clineHookFile - temporaryPath string -} - -type clineRenameFunc func(source, destination string) error - func readClineHookFile(filePath string) (clineHookFile, error) { - // #nosec G304 -- filePath is resolved from Cline's native hook directory. - raw, err := os.ReadFile(filePath) + snapshot, err := readManagedFile(filePath, "Cline hook", maxManagedSourceBytes) if err != nil { - if os.IsNotExist(err) { - return clineHookFile{filePath: filePath}, nil - } - return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err) + return clineHookFile{}, err } - source := string(raw) + if snapshot == nil { + return clineHookFile{filePath: filePath}, nil + } + source := string(snapshot.contents) metadata, err := parseClineMetadata(filePath, source) if err != nil { return clineHookFile{}, err } return clineHookFile{ - exists: true, - filePath: filePath, - source: source, - metadata: metadata, + exists: true, filePath: filePath, source: source, metadata: metadata, }, nil } +func readClineHookPair() ( + clineHookPaths, + clineHookFile, + clineHookFile, + error, +) { + paths, err := resolveClineHookFiles() + if err != nil { + return clineHookPaths{}, clineHookFile{}, clineHookFile{}, err + } + if err := validateClineHookDirectories(paths); err != nil { + return paths, clineHookFile{}, clineHookFile{}, err + } + guard, err := readClineHookFile(paths.guardPath) + if err != nil { + return paths, clineHookFile{}, clineHookFile{}, err + } + audit, err := readClineHookFile(paths.auditPath) + if err != nil { + return paths, clineHookFile{}, clineHookFile{}, err + } + return paths, guard, audit, nil +} + func requireAvailableClineHook(file clineHookFile) error { if file.exists && file.metadata == nil { return fmt.Errorf( @@ -52,160 +68,80 @@ func requireAvailableClineHook(file clineHookFile) error { return nil } -func removeClineTemporary(path string) error { - if path == "" { +func writeClineHookChanges( + changes []*fileChange, + label string, + rename renameFunc, + paths clineHookPaths, +) error { + if !hasClineChanges(changes) { return nil } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err) + if err := ensureManagedDirectory( + paths.clineDirectory, + "Cline configuration directory", + ); err != nil { + return err } - return nil + if err := ensureManagedDirectory(paths.hooksDirectory, "Cline hooks directory"); err != nil { + return err + } + return writeChanges(changes, label, rename) } -func failClineStage(temporary *os.File, path string, cause error) error { - closeErr := temporary.Close() - removeErr := removeClineTemporary(path) - return errors.Join(cause, closeErr, removeErr) -} - -func stageClineHook(write clinePendingWrite) (clineStagedFile, error) { - directory := filepath.Dir(write.state.filePath) - if err := os.MkdirAll(directory, 0700); err != nil { - return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err) - } - temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp") - if err != nil { - return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err) - } - temporaryPath := temporary.Name() - if err := temporary.Chmod(0700); err != nil { - return clineStagedFile{}, failClineStage( - temporary, - temporaryPath, - fmt.Errorf("set permissions for %s: %w", temporaryPath, err), - ) - } - written, err := temporary.WriteString(write.source) - if err != nil { - return clineStagedFile{}, failClineStage( - temporary, - temporaryPath, - fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err), - ) - } - if written != len(write.source) { - return clineStagedFile{}, failClineStage( - temporary, - temporaryPath, - fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite), - ) - } - if err := temporary.Sync(); err != nil { - return clineStagedFile{}, failClineStage( - temporary, - temporaryPath, - fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err), - ) - } - if err := temporary.Close(); err != nil { - return clineStagedFile{}, errors.Join( - fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err), - removeClineTemporary(temporaryPath), - ) - } - return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil -} - -func rollbackClineHook(state clineHookFile) error { - if state.exists { - return writeHookFileAtomic(state.filePath, []byte(state.source), 0700) - } - return removeHookFile(state.filePath, "Cline hook rollback") -} - -func writeClineHookPair(guard, audit clinePendingWrite) error { - return writeClineHookPairWithRename(guard, audit, os.Rename) -} - -func writeClineHookPairWithRename( - guard, audit clinePendingWrite, - rename clineRenameFunc, +func writeClineChanges( + changes []*fileChange, + label string, + rename renameFunc, + paths *clineRuntimePaths, ) error { - writes := []clinePendingWrite{guard, audit} - staged := make([]clineStagedFile, 0, len(writes)) - for _, write := range writes { - file, err := stageClineHook(write) - if err != nil { - failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)} - for _, item := range staged { - if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil { - failures = append(failures, cleanupErr) - } - } - return errors.Join(failures...) - } - staged = append(staged, file) + if !hasClineChanges(changes) { + return nil } - - committed := 0 - for index, item := range staged { - if err := rename(item.temporaryPath, item.state.filePath); err != nil { - failures := []error{ - fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err), - } - for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- { - if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil { - failures = append(failures, fmt.Errorf( - "restore Cline hook at %s: %w", - staged[rollbackIndex].state.filePath, - rollbackErr, - )) - } - } - for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ { - if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil { - failures = append(failures, cleanupErr) - } - } - return errors.Join(failures...) - } - committed++ + if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil { + return err } - return nil + if err := EnsurePrivateDirectory(paths.agentDirectory); err != nil { + return err + } + return writeClineHookChanges(changes, label, rename, paths.hooks) } -func removeOwnedClineHooks(files []clineHookFile, agentID string) error { +func hasClineChanges(changes []*fileChange) bool { + for _, change := range changes { + if change != nil { + return true + } + } + return false +} + +func prepareClineUninstallChanges( + files []clineHookFile, + agentID string, +) ([]*fileChange, error) { + changes := make([]*fileChange, 0, len(files)) for _, file := range files { - if file.metadata == nil { + if file.metadata == nil || + (agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) { continue } - if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) { - continue + if err := assertClineWrapperIntegrity(file); err != nil { + return nil, err } - if err := removeHookFile(file.filePath, "Cline hook"); err != nil { - return err + change, err := prepareSourceChange( + file.filePath, + fmt.Sprintf("Cline %s hook", file.metadata.Kind), + []byte(file.source), + file.exists, + nil, + 0700, + true, + ) + if err != nil { + return nil, err } + changes = append(changes, change) } - return nil -} - -func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { - configPath := filepath.Join(contract.agentDirectory, "config.json") - config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") - if err != nil || !exists { - return false, err - } - agentID, ok := config["agent_id"].(string) - if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) { - return false, nil - } - guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") - if err != nil { - return false, err - } - auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") - if err != nil { - return false, err - } - return guardExists && auditExists, nil + return changes, nil } diff --git a/sdks/go/cmd/elydora/plugins/cline_official_test.go b/sdks/go/cmd/elydora/plugins/cline_official_test.go new file mode 100644 index 0000000..253653f --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/cline_official_test.go @@ -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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/cline_recovery_test.go b/sdks/go/cmd/elydora/plugins/cline_recovery_test.go index d6450f5..1a639f8 100644 --- a/sdks/go/cmd/elydora/plugins/cline_recovery_test.go +++ b/sdks/go/cmd/elydora/plugins/cline_recovery_test.go @@ -8,119 +8,174 @@ import ( "testing" ) -func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) { - fixture := prepareClineFixture(t, clineFixtureOptions{}) - installClineFixture(t, fixture) - status, err := fixture.plugin.Status() - if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { - t.Fatalf("installed status = %#v, %v", status, err) - } - if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir { - t.Fatalf("status metadata = %#v", status) - } - if err := os.Remove(fixture.guardPath); err != nil { - t.Fatalf("remove guard runtime: %v", err) - } - status, err = fixture.plugin.Status() - if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { - t.Fatalf("degraded status = %#v, %v", status, err) - } - if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil { - t.Fatalf("restore guard runtime: %v", err) - } - if err := os.Remove(fixture.auditWrapper); err != nil { - t.Fatalf("remove audit wrapper: %v", err) - } - status, err = fixture.plugin.Status() - if err != nil || status.Installed || status.HookConfigured { - t.Fatalf("partial status = %#v, %v", status, err) - } -} - -func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) { - fixture := prepareClineFixture(t, clineFixtureOptions{}) - installClineFixture(t, fixture) - guard := readClineTestFile(t, fixture.guardWrapper) - if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil { - t.Fatalf("tamper guard wrapper: %v", err) - } - if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") { - t.Fatalf("tampered status error = %v", err) - } - installClineFixture(t, fixture) - if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { - t.Fatalf("corrupt runtime config: %v", err) - } - if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { - t.Fatalf("runtime status error = %v", err) - } -} - -func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) { - for _, collision := range []string{"guard", "audit"} { - t.Run(collision, func(t *testing.T) { - options := clineFixtureOptions{} - if collision == "guard" { - options.ExistingGuard = clineString("// user PreToolUse hook\n") - } else { - options.ExistingAudit = clineString("// user PostToolUse hook\n") +func TestClineStatusRequiresExactPhysicalInstallation(t *testing.T) { + for _, target := range []string{ + "config", "key", "guard", "audit", "guard-wrapper", "audit-wrapper", + } { + t.Run("missing "+target, func(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { + t.Fatalf("installed status = %#v, %v", status, err) } - fixture := prepareClineFixture(t, options) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "owned by another integration") { - t.Fatalf("install error = %v", err) + paths := map[string]string{ + "config": fixture.runtimeConfig, "key": fixture.privateKey, + "guard": fixture.guardPath, "audit": fixture.hookPath, + "guard-wrapper": fixture.guardWrapper, + "audit-wrapper": fixture.auditWrapper, } - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { - requireMissingClineTestFile(t, path) + if err := os.Remove(paths[target]); err != nil { + t.Fatalf("remove %s: %v", target, err) } - if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard { - t.Fatalf("user guard wrapper changed") + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookScriptExists { + t.Fatalf("missing %s status = %#v, %v", target, status, err) } - if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit { - t.Fatalf("user audit wrapper changed") + if strings.Contains(target, "wrapper") && status.HookConfigured { + t.Fatalf("missing wrapper remains configured: %#v", status) } }) } } -func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) { - corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n" - fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt}) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") { - t.Fatalf("install error = %v", err) +func TestClineStatusSurfacesTamperingAndInvalidRuntimeMetadata(t *testing.T) { + tests := []struct { + name, target, source, want string + }{ + {"guard-source", "guard", "tampered\n", ""}, + {"audit-source", "audit", "tampered\n", ""}, + {"private-key", "key", "invalid", "canonical 32-byte"}, + {"config-malformed", "config", "{ malformed", "parse Elydora runtime config"}, + {"config-duplicate", "config", `{"agent_name":"cline","agent_name":"cline"}`, "duplicate key"}, + {"config-unsupported", "config", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"cline","extra":true}`, "unsupported field"}, + {"config-identity", "config", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"cline"}`, "identity does not match"}, + {"guard-wrapper", "guard-wrapper", "tamper", "managed template"}, } - if readClineTestFile(t, fixture.guardWrapper) != corrupt { - t.Fatalf("corrupt hook changed") + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + paths := map[string]string{ + "guard": fixture.guardPath, "audit": fixture.hookPath, + "key": fixture.privateKey, "config": fixture.runtimeConfig, + "guard-wrapper": fixture.guardWrapper, + } + source := testCase.source + if testCase.target == "guard-wrapper" { + source = readClineTestFile(t, paths[testCase.target]) + testCase.source + } + writeClineTestFile(t, paths[testCase.target], []byte(source), 0600) + status, err := fixture.plugin.Status() + if testCase.want == "" { + if err != nil || status.Installed || status.HookScriptExists { + t.Fatalf("tampered status = %#v, %v", status, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("status error = %v, want %q", err, testCase.want) + } + }) } - requireMissingClineTestFile(t, fixture.auditWrapper) - requireMissingClineTestFile(t, fixture.hookPath) } -func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) { - fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true}) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { - t.Fatalf("install error = %v", err) - } - for _, path := range []string{ - fixture.guardWrapper, - fixture.auditWrapper, - fixture.hookPath, - fixture.runtimeConfig, - fixture.privateKey, +func TestClineStatusRejectsInvalidHookContracts(t *testing.T) { + for _, testCase := range []struct { + name, kind, agentID, runtimePath, want string + }{ + {"kind", "guard", clineTestAgentID, "", "mismatched event metadata"}, + {"agent", "audit", "agent-2", "", "different agents"}, + {"path", "audit", clineTestAgentID, "outside", "unexpected runtime path"}, + {"segment", "audit", "..", "", "invalid agentId"}, } { - requireMissingClineTestFile(t, path) + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClineFixture(t, clineFixtureOptions{}) + installClineFixture(t, fixture) + runtimePath := testCase.runtimePath + if runtimePath == "outside" { + runtimePath = filepath.Join(filepath.Dir(filepath.Dir(fixture.agentDir)), "outside", clineAuditScript) + } + if runtimePath == "" { + runtimePath = fixture.hookPath + if testCase.agentID == "agent-2" { + runtimePath = filepath.Join(filepath.Dir(fixture.agentDir), "agent-2", clineAuditScript) + } + } + metadata, err := buildClineMetadata( + testCase.kind, + testCase.agentID, + runtimePath, + ) + if err != nil { + t.Fatalf("build invalid contract metadata: %v", err) + } + source, err := buildClineWrapper(metadata) + if err != nil { + t.Fatalf("build invalid contract wrapper: %v", err) + } + writeClineTestFile(t, fixture.auditWrapper, []byte(source), 0700) + if testCase.name == "segment" { + guardMetadata, buildErr := buildClineMetadata( + "guard", + testCase.agentID, + fixture.guardPath, + ) + if buildErr != nil { + t.Fatalf("build invalid guard metadata: %v", buildErr) + } + guardSource, buildErr := buildClineWrapper(guardMetadata) + if buildErr != nil { + t.Fatalf("build invalid guard wrapper: %v", buildErr) + } + writeClineTestFile(t, fixture.guardWrapper, []byte(guardSource), 0700) + } + _, err = fixture.plugin.Status() + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("contract error = %v, want %q", err, testCase.want) + } + }) } } -func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) { +func TestClineInstallRejectsCollisionsAndCorruptOwnershipBeforeWrites(t *testing.T) { + for _, testCase := range []struct { + name string + opts clineFixtureOptions + want string + }{ + {"guard-collision", clineFixtureOptions{ExistingGuard: clineString("// user PreToolUse hook\n")}, "owned by another integration"}, + {"audit-collision", clineFixtureOptions{ExistingAudit: clineString("// user PostToolUse hook\n")}, "owned by another integration"}, + {"corrupt-metadata", clineFixtureOptions{ExistingGuard: clineString("#!/usr/bin/env node\n// @elydora-cline-hook invalid\n")}, "parse Elydora Cline hook metadata"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClineFixture(t, testCase.opts) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v", err) + } + for path, expected := range map[string]*string{ + fixture.guardWrapper: testCase.opts.ExistingGuard, + fixture.auditWrapper: testCase.opts.ExistingAudit, + } { + if expected != nil && readClineTestFile(t, path) != *expected { + t.Fatalf("existing hook changed at %s", path) + } + } + for _, path := range []string{ + fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey, + } { + requireMissingClineTestFile(t, path) + } + }) + } +} + +func TestClineUninstallRemovesExactOwnershipAndPreservesAdjacentHooks(t *testing.T) { fixture := prepareClineFixture(t, clineFixtureOptions{}) installClineFixture(t, fixture) userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py") - if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil { - t.Fatalf("write user hook: %v", err) - } + writeClineTestFile(t, userHook, []byte("# user hook\n"), 0600) if err := fixture.plugin.Uninstall("agent-10"); err != nil { t.Fatalf("uninstall other agent: %v", err) } @@ -129,78 +184,21 @@ func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil { t.Fatalf("uninstall Cline hooks: %v", err) } - requireMissingClineTestFile(t, fixture.guardWrapper) - requireMissingClineTestFile(t, fixture.auditWrapper) + for _, path := range []string{fixture.guardWrapper, fixture.auditWrapper} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed hook remains at %s: %v", path, err) + } + } if readClineTestFile(t, userHook) != "# user hook\n" { - t.Fatalf("user hook changed") + t.Fatal("adjacent user hook changed") } } -func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) { +func TestClineRuntimeConfigOmitsEmptyToken(t *testing.T) { fixture := prepareClineFixture(t, clineFixtureOptions{}) + fixture.config.Token = "" installClineFixture(t, fixture) - originalGuard := readClineTestFile(t, fixture.guardWrapper) - originalAudit := readClineTestFile(t, fixture.auditWrapper) - guardState, err := readClineHookFile(fixture.guardWrapper) - if err != nil { - t.Fatalf("read guard state: %v", err) - } - auditState, err := readClineHookFile(fixture.auditWrapper) - if err != nil { - t.Fatalf("read audit state: %v", err) - } - guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath) - if err != nil { - t.Fatalf("build replacement guard metadata: %v", err) - } - auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath) - if err != nil { - t.Fatalf("build replacement audit metadata: %v", err) - } - replacementGuard, err := buildClineWrapper(guardMetadata) - if err != nil { - t.Fatalf("build replacement guard: %v", err) - } - replacementAudit, err := buildClineWrapper(auditMetadata) - if err != nil { - t.Fatalf("build replacement audit: %v", err) - } - failureInjected := false - rename := func(source, destination string) error { - if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) { - failureInjected = true - return errors.New("simulated audit commit failure") - } - return os.Rename(source, destination) - } - err = writeClineHookPairWithRename( - clinePendingWrite{state: guardState, source: replacementGuard}, - clinePendingWrite{state: auditState, source: replacementAudit}, - rename, - ) - if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") { - t.Fatalf("write pair error = %v", err) - } - if !failureInjected { - t.Fatalf("audit commit failure was not injected") - } - if readClineTestFile(t, fixture.guardWrapper) != originalGuard || - readClineTestFile(t, fixture.auditWrapper) != originalAudit { - t.Fatalf("hook pair was not restored") - } - entries, err := os.ReadDir(fixture.hooksDir) - if err != nil { - t.Fatalf("read hook directory: %v", err) - } - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("temporary hook remains: %s", entry.Name()) - } + if _, exists := readClineTestObject(t, fixture.runtimeConfig)["token"]; exists { + t.Fatal("empty token persisted") } } - -func sameClineTestPath(left, right string) bool { - leftPath, leftErr := filepath.Abs(left) - rightPath, rightErr := filepath.Abs(right) - return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath) -} diff --git a/sdks/go/cmd/elydora/plugins/cline_runtime_test.go b/sdks/go/cmd/elydora/plugins/cline_runtime_test.go new file mode 100644 index 0000000..5eb3b3b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/cline_runtime_test.go @@ -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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/cline_status.go b/sdks/go/cmd/elydora/plugins/cline_status.go new file mode 100644 index 0000000..daeef04 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/cline_status.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/cline_test.go b/sdks/go/cmd/elydora/plugins/cline_test.go index 68a003c..429813b 100644 --- a/sdks/go/cmd/elydora/plugins/cline_test.go +++ b/sdks/go/cmd/elydora/plugins/cline_test.go @@ -17,37 +17,72 @@ func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) { if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" { t.Fatalf("Cline registry entry = %#v", entry) } - if _, ok := NewPlugin("cline").(*ClinePlugin); !ok { + plugin, ok := NewPlugin("cline").(*ClinePlugin) + if !ok { t.Fatalf("Cline plugin is not registered") } + if !plugin.ManagesGuardRuntime() { + t.Fatal("Cline plugin must own its runtime transaction") + } } -func TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) { +func TestClineInstallWritesAllSixManagedFilesAtomicallyAndIsIdempotent(t *testing.T) { fixture := prepareClineFixture(t, clineFixtureOptions{}) installClineFixture(t, fixture) - originalGuard := readClineTestFile(t, fixture.guardWrapper) - originalAudit := readClineTestFile(t, fixture.auditWrapper) + files := []string{ + fixture.guardPath, + fixture.runtimeConfig, + fixture.privateKey, + fixture.hookPath, + fixture.guardWrapper, + fixture.auditWrapper, + } + original := make(map[string]string, len(files)) + for _, path := range files { + original[path] = readClineTestFile(t, path) + } installClineFixture(t, fixture) - if readClineTestFile(t, fixture.guardWrapper) != originalGuard { - t.Fatalf("guard wrapper changed during idempotent install") - } - if readClineTestFile(t, fixture.auditWrapper) != originalAudit { - t.Fatalf("audit wrapper changed during idempotent install") - } - if !strings.HasPrefix(originalGuard, "#!/usr/bin/env node\n// @elydora-cline-hook ") { - t.Fatalf("guard wrapper has unexpected header: %q", originalGuard) - } - entries, err := os.ReadDir(fixture.hooksDir) - if err != nil { - t.Fatalf("read Cline hook directory: %v", err) - } - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("temporary hook file remains: %s", entry.Name()) + for path, source := range original { + if readClineTestFile(t, path) != source { + t.Fatalf("%s changed during idempotent install", path) } } + if !strings.HasPrefix(original[fixture.guardWrapper], "#!/usr/bin/env node\n// @elydora-cline-hook ") { + t.Fatalf("guard wrapper has unexpected header: %q", original[fixture.guardWrapper]) + } + if original[fixture.guardPath] != generateGuardScript( + clineAgentKey, + clineTestAgentID, + "", + false, + "", + ) { + t.Fatal("guard runtime does not match the generated source") + } + if original[fixture.hookPath] != buildHookScriptWithOutput( + clineAgentKey, + clineTestAgentID, + "", + false, + true, + ) { + t.Fatal("audit runtime does not preserve the native payload") + } + if original[fixture.privateKey] != clinePrivateKey { + t.Fatal("private key changed during installation") + } + config := readClineTestObject(t, fixture.runtimeConfig) + wantConfig := map[string]any{ + "org_id": "org-1", "agent_id": clineTestAgentID, "kid": "kid-1", + "base_url": "https://api.elydora.test", "token": "token-1", + "agent_name": clineAgentKey, + } + if !reflect.DeepEqual(config, wantConfig) { + t.Fatalf("runtime config = %#v", config) + } + assertNoClineTransactionArtifacts(t, fixture.homeDir) requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs")) requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs")) requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs")) @@ -67,6 +102,13 @@ func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) { func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) { fixture := prepareClineFixture(t, clineFixtureOptions{}) installClineFixture(t, fixture) + if err := os.WriteFile( + fixture.guardPath, + []byte("process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"), + 0700, + ); err != nil { + t.Fatalf("write frozen guard runtime: %v", err) + } capturePath := filepath.Join(t.TempDir(), "captured-event.json") captureJSON, err := json.Marshal(capturePath) if err != nil { @@ -123,7 +165,32 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) { fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture.config.BaseURL = server.URL installClineFixture(t, fixture) - payload := []byte(`{"hookName":"tool_result","taskId":"task-1","tool_result":{"name":"read_file","input":{"path":"README.md"},"output":"ok"}}`) + payloadObject := map[string]any{ + "clineVersion": "3.0.46", + "hookName": "tool_result", + "timestamp": "2026-07-19T12:00:00.000Z", + "taskId": "task-1", + "workspaceRoots": []any{ + fixture.workspaceDir, + }, + "userId": "user-1", + "agent_id": "cline-agent", + "parent_agent_id": nil, + "tool_result": map[string]any{ + "id": "call-1", "name": "read_file", + "input": map[string]any{"path": "README.md"}, + "output": "ok", "durationMs": float64(5), + }, + "postToolUse": map[string]any{ + "toolName": "read_file", + "parameters": map[string]any{"path": "README.md"}, + "result": "ok", "success": true, "executionTimeMs": float64(5), + }, + } + payload, err := json.Marshal(payloadObject) + if err != nil { + t.Fatalf("marshal Cline payload: %v", err) + } result := runClineWrapper(t, fixture, fixture.auditWrapper, payload) if result.exitCode != 0 { t.Fatalf("audit wrapper result = %#v", result) @@ -135,12 +202,7 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("timed out waiting for Elydora operation") } - wantPayload := map[string]any{ - "tool_name": "read_file", - "tool_input": map[string]any{"path": "README.md"}, - "session_id": "task-1", - } - if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) { + if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), payloadObject) { t.Fatalf("operation payload = %#v", operation["payload"]) } if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) { @@ -152,10 +214,11 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) { } func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) { - passing := prepareClineFixture(t, clineFixtureOptions{ - GuardSource: clineString("process.stdin.resume();\n"), - }) + passing := prepareClineFixture(t, clineFixtureOptions{}) installClineFixture(t, passing) + if err := os.WriteFile(passing.guardPath, []byte("process.stdin.resume();\n"), 0700); err != nil { + t.Fatalf("write passing guard runtime: %v", err) + } passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`)) if passResult.exitCode != 0 || passResult.stdout != "" { t.Fatalf("passing guard result = %#v", passResult) diff --git a/sdks/go/cmd/elydora/plugins/cline_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/cline_test_helpers_test.go index d6cc590..1b62626 100644 --- a/sdks/go/cmd/elydora/plugins/cline_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/cline_test_helpers_test.go @@ -8,17 +8,20 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "strings" "testing" "time" ) -const clineTestAgentID = "agent-1" +const ( + clineTestAgentID = "agent-1" + clinePrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE" +) type clineFixtureOptions struct { ExistingAudit *string ExistingGuard *string - GuardSource *string - SkipGuard bool } type clineFixture struct { @@ -60,20 +63,11 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs") auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs") - for _, directory := range []string{workspaceDir, agentDir} { + for _, directory := range []string{workspaceDir} { if err := os.MkdirAll(directory, 0755); err != nil { t.Fatalf("create fixture directory %s: %v", directory, err) } } - if !options.SkipGuard { - guardSource := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" - if options.GuardSource != nil { - guardSource = *options.GuardSource - } - if err := os.WriteFile(guardPath, []byte(guardSource), 0700); err != nil { - t.Fatalf("write guard runtime: %v", err) - } - } writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard) writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit) @@ -97,8 +91,9 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur AgentName: "cline", OrgID: "org-1", AgentID: clineTestAgentID, - PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", + PrivateKey: clinePrivateKey, KID: "kid-1", + Token: "token-1", BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, }, @@ -193,13 +188,97 @@ func requireClineTestObject(t *testing.T, value any) map[string]any { func decodeClineControl(t *testing.T, stdout string) map[string]any { t.Helper() - const marker = "HOOK_CONTROL\t" - if len(stdout) < len(marker) || stdout[:len(marker)] != marker { - t.Fatalf("Cline control output = %q", stdout) - } var control map[string]any - if err := json.Unmarshal([]byte(stdout[len(marker):]), &control); err != nil { + if err := json.Unmarshal([]byte(stdout), &control); err != nil { t.Fatalf("decode Cline control output: %v", err) } return control } + +func writeClineTestFile( + t *testing.T, + path string, + contents []byte, + mode os.FileMode, +) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create parent directory for %s: %v", path, err) + } + if err := os.WriteFile(path, contents, mode); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func writeClineTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal Cline test object: %v", err) + } + writeClineTestFile(t, path, append(encoded, '\n'), 0600) +} + +func readClineTestObject(t *testing.T, path string) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal([]byte(readClineTestFile(t, path)), &value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func assertNoClineRuntimeWrites(t *testing.T, fixture *clineFixture) { + t.Helper() + for _, path := range []string{ + fixture.guardPath, + fixture.runtimeConfig, + fixture.privateKey, + fixture.hookPath, + fixture.guardWrapper, + fixture.auditWrapper, + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed file exists at %s: %v", path, err) + } + } +} + +func assertNoClineTransactionArtifacts(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + name := entry.Name() + if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".rollback") { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Cline fixture: %v", err) + } +} + +func clineSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if runtime.GOOS == "windows" { + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + return + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } +} + +func sameClineTestPath(left, right string) bool { + leftPath, leftErr := filepath.Abs(left) + rightPath, rightErr := filepath.Abs(right) + return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath) +} diff --git a/sdks/go/cmd/elydora/plugins/cline_transaction_test.go b/sdks/go/cmd/elydora/plugins/cline_transaction_test.go new file mode 100644 index 0000000..97f9ffd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/cline_transaction_test.go @@ -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) +}