fix(cline): make Go hook installation transactional
This commit is contained in:
1 parent
bb7186630d
commit
050d0043ea
14 files changed
+1681
-484
No files matched your search
+104
-168
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user