fix(cline): make Go hook installation transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 11:33:44 -04:00
1 parent bb7186630d
commit 050d0043ea
14 files changed
+1681 -484

No files matched your search

+104 -168
View File
@@ -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
}