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
@@ -0,0 +1,368 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func prepareClineTestChanges(
|
||||
t *testing.T,
|
||||
fixture *clineFixture,
|
||||
) ([]*fileChange, *clineRuntimePaths) {
|
||||
t.Helper()
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
t.Fatalf("read Cline hook pair: %v", err)
|
||||
}
|
||||
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Cline installation: %v", err)
|
||||
}
|
||||
changes, err := prepareClineInstallationChanges(
|
||||
fixture.config,
|
||||
runtimePaths,
|
||||
guard,
|
||||
audit,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare Cline installation: %v", err)
|
||||
}
|
||||
return changes, runtimePaths
|
||||
}
|
||||
|
||||
func snapshotClineFiles(t *testing.T, paths ...string) map[string]string {
|
||||
t.Helper()
|
||||
result := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
result[path] = readClineTestFile(t, path)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func requireClineSnapshot(t *testing.T, snapshot map[string]string) {
|
||||
t.Helper()
|
||||
for path, expected := range snapshot {
|
||||
if actual := readClineTestFile(t, path); actual != expected {
|
||||
t.Fatalf("snapshot changed at %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallRollsBackAllSixFiles(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
failed = true
|
||||
return errors.New("injected Cline audit hook failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Cline audit hook failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
if !failed {
|
||||
t.Fatal("audit hook failure was not injected")
|
||||
}
|
||||
assertNoClineRuntimeWrites(t, fixture)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallRestoresAllSixExistingFilesAfterCommitFailure(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
|
||||
"org_id": "old-org", "agent_id": clineTestAgentID, "kid": "old-kid",
|
||||
"base_url": "https://old.elydora.test", "agent_name": clineAgentKey,
|
||||
})
|
||||
for path, source := range map[string]string{
|
||||
fixture.guardPath: "stale guard\n",
|
||||
fixture.privateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
|
||||
fixture.hookPath: "stale audit\n",
|
||||
fixture.guardWrapper: readClineTestFile(t, fixture.guardWrapper) + "// stale\n",
|
||||
fixture.auditWrapper: readClineTestFile(t, fixture.auditWrapper) + "// stale\n",
|
||||
} {
|
||||
writeClineTestFile(t, path, []byte(source), 0600)
|
||||
}
|
||||
paths := []string{
|
||||
fixture.guardPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.hookPath,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
}
|
||||
before := snapshotClineFiles(t, paths...)
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
failed = true
|
||||
return errors.New("injected existing Cline audit hook failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected existing Cline audit hook failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestPreparedClineInstallRejectsStaleHookBeforeStaging(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
paths, guard, audit, err := readClineHookPair()
|
||||
if err != nil {
|
||||
t.Fatalf("read Cline hook pair: %v", err)
|
||||
}
|
||||
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight Cline installation: %v", err)
|
||||
}
|
||||
concurrent := []byte("// concurrent user hook\n")
|
||||
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
|
||||
_, err = prepareClineInstallationChanges(
|
||||
fixture.config,
|
||||
runtimePaths,
|
||||
guard,
|
||||
audit,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed before update") {
|
||||
t.Fatalf("stale hook error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
|
||||
t.Fatal("concurrent hook changed")
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedClineInstallRejectsConcurrentHookChange(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
changes, paths := prepareClineTestChanges(t, fixture)
|
||||
concurrent := []byte("// concurrent user hook\n")
|
||||
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
|
||||
err := writeClineChanges(changes, "Install Cline hooks", nil, paths)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent hook error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
|
||||
t.Fatal("concurrent hook changed")
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallDetectsConcurrentHookIdentityReplacement(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
tampered := readClineTestFile(t, fixture.auditWrapper) + "// stale\n"
|
||||
writeClineTestFile(t, fixture.auditWrapper, []byte(tampered), 0700)
|
||||
fixture.config.OrgID = "org-updated"
|
||||
before := snapshotClineFiles(
|
||||
t,
|
||||
fixture.guardPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.hookPath,
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
)
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||
mutated = true
|
||||
external := fixture.auditWrapper + ".external"
|
||||
if err := os.Rename(fixture.auditWrapper, external); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(fixture.auditWrapper, []byte(tampered), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("concurrent identity error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineOrphanRuntimeArtifactsFailBeforeHookWrites(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"private.key", clineGuardScript, clineAuditScript,
|
||||
"chain-state.json", "status-cache.json", "error.log",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
artifact := filepath.Join(fixture.agentDir, name)
|
||||
writeClineTestFile(t, artifact, []byte("orphan\n"), 0600)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
|
||||
t.Fatalf("orphan runtime error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, artifact) != "orphan\n" {
|
||||
t.Fatal("orphan artifact changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
|
||||
"agent_id": "another-agent", "agent_name": clineAgentKey,
|
||||
})
|
||||
original := readClineTestFile(t, fixture.runtimeConfig)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("identity error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.runtimeConfig) != original {
|
||||
t.Fatal("runtime config changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
}
|
||||
|
||||
func TestClineLinkedDirectoriesAndFilesAreRejected(t *testing.T) {
|
||||
for _, kind := range []string{
|
||||
"configuration", "hooks", "runtime", "agent-directory", "runtime-config", "hook",
|
||||
} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
target := filepath.Join(t.TempDir(), kind+"-target")
|
||||
switch kind {
|
||||
case "configuration":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.clineDir)
|
||||
case "hooks":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(fixture.clineDir, 0700); err != nil {
|
||||
t.Fatalf("create Cline directory: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.hooksDir)
|
||||
case "runtime":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir))
|
||||
case "agent-directory":
|
||||
if err := os.MkdirAll(target, 0700); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil {
|
||||
t.Fatalf("create runtime root: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.agentDir)
|
||||
case "runtime-config":
|
||||
writeClineTestFile(t, target, []byte(`{"agent_id":"agent-1","agent_name":"cline"}`), 0600)
|
||||
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||
t.Fatalf("create agent directory: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.runtimeConfig)
|
||||
case "hook":
|
||||
installClineFixture(t, fixture)
|
||||
writeClineTestFile(t, target, []byte("external hook\n"), 0600)
|
||||
if err := os.Remove(fixture.guardWrapper); err != nil {
|
||||
t.Fatalf("remove guard wrapper: %v", err)
|
||||
}
|
||||
clineSymlinkOrSkip(t, target, fixture.guardWrapper)
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "physical") {
|
||||
t.Fatalf("linked %s error = %v", kind, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallValidatesInputsBeforeWrites(t *testing.T) {
|
||||
tests := []struct{ name, field, value, want string }{
|
||||
{"agent-name", "agent_name", "codex", "requires agent name cline"},
|
||||
{"agent-id", "agent_id", "../escape", "invalid agent ID"},
|
||||
{"organization", "org_id", " ", "organization ID is required"},
|
||||
{"key-id", "kid", " ", "key ID is required"},
|
||||
{"private-key", "private_key", "invalid", "canonical 32-byte"},
|
||||
{"token", "token", " ", "non-whitespace"},
|
||||
{"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"},
|
||||
{"guard-path", "guard_script_path", "outside", "managed agent directory"},
|
||||
{"audit-path", "hook_script", "outside", "managed agent directory"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
switch testCase.field {
|
||||
case "agent_name":
|
||||
fixture.config.AgentName = testCase.value
|
||||
case "agent_id":
|
||||
fixture.config.AgentID = testCase.value
|
||||
case "org_id":
|
||||
fixture.config.OrgID = testCase.value
|
||||
case "kid":
|
||||
fixture.config.KID = testCase.value
|
||||
case "private_key":
|
||||
fixture.config.PrivateKey = testCase.value
|
||||
case "token":
|
||||
fixture.config.Token = testCase.value
|
||||
case "base_url":
|
||||
fixture.config.BaseURL = testCase.value
|
||||
case "guard_script_path":
|
||||
fixture.config.GuardScriptPath = testCase.value
|
||||
case "hook_script":
|
||||
fixture.config.HookScript = testCase.value
|
||||
}
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, testCase.want)
|
||||
}
|
||||
assertNoClineRuntimeWrites(t, fixture)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRestoresBothHooksWhenSecondRemovalFails(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
before := snapshotClineFiles(t, fixture.guardWrapper, fixture.auditWrapper)
|
||||
calls := 0
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("injected Cline uninstall failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err := fixture.plugin.Uninstall(clineTestAgentID)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Cline uninstall failure") {
|
||||
t.Fatalf("uninstall error = %v", err)
|
||||
}
|
||||
requireClineSnapshot(t, before)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestClineInstallLeavesNoTransactionArtifacts(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
assertNoClineTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
Reference in new issue
Block a user