fix(go): mirror native Gemini CLI hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 08:45:09 -04:00
1 parent 2f223dca73
commit 2a4e62f0a3
16 files changed
+3129 -169

No files matched your search

+118 -169
View File
@@ -2,213 +2,162 @@ package plugins
import (
"fmt"
"os"
"path/filepath"
)
// GeminiPlugin manages the Elydora audit hook for Gemini CLI.
// It merges an AfterTool hook into ~/.gemini/settings.json.
type GeminiPlugin struct{}
// GeminiPlugin manages Gemini CLI global user hooks.
type GeminiPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that Gemini commits both generated runtimes and
// the user settings document through one transaction.
func (p *GeminiPlugin) ManagesGuardRuntime() bool {
return true
}
func requireGeminiHooksEnabled(document *geminiDocument) error {
if !document.hookControls.enabled {
return fmt.Errorf(
"Gemini CLI hooks are disabled by hooksConfig.enabled: %s",
document.filePath,
)
}
disabled, err := disabledManagedGeminiEntries(document.hookControls)
if err != nil {
return err
}
if len(disabled) > 0 {
return fmt.Errorf(
"Gemini CLI hooks are disabled by hooksConfig.disabled: %v",
disabled,
)
}
return nil
}
// PreflightInstall validates settings and runtime identity before any write.
func (p *GeminiPlugin) PreflightInstall(config InstallConfig) error {
document, err := readGeminiDocument()
if err != nil {
return err
}
if err := requireGeminiHooksEnabled(document); err != nil {
return err
}
_, _, err = preflightGeminiInstallation(config, document)
return err
}
func (p *GeminiPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID)
document, err := readGeminiDocument()
if err != nil {
return err
}
if config.HookScript != "" {
scriptPath = config.HookScript
if err := requireGeminiHooksEnabled(document); err != nil {
return err
}
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
if err != nil {
return err
}
}
configDir, err := expandHome("~/.gemini")
paths, nodePath, err := preflightGeminiInstallation(config, document)
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
guardGroup, err := buildGeminiGroup(
nodePath,
paths.guardPath,
geminiGuardHookName,
)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
auditGroup, err := buildGeminiGroup(
nodePath,
paths.auditPath,
geminiAuditHookName,
)
if err != nil {
return err
}
// --- BeforeTool (guard — freeze enforcement) ---
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
rendered, err := renderGeminiDocument(
document,
"",
map[string]map[string]any{
"BeforeTool": guardGroup,
"AfterTool": auditGroup,
},
)
if err != nil {
return fmt.Errorf("render Gemini CLI user settings: %w", err)
}
preFiltered = append(preFiltered, guardEntry)
hooks["BeforeTool"] = preFiltered
// --- AfterTool (audit logging) ---
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
hookEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + scriptPath,
},
},
}
postFiltered = append(postFiltered, hookEntry)
hooks["AfterTool"] = postFiltered
settings["hooks"] = hooks
if err := writeJSONFile(configPath, settings); err != nil {
changes, err := prepareGeminiInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
fmt.Printf("Installed Elydora hook for Gemini CLI at %s\n", configPath)
if err := writeGeminiChanges(
changes,
"Install Gemini CLI hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
); err != nil {
return err
}
fmt.Printf("Gemini CLI hooks installed at %s.\n", document.filePath)
fmt.Println("Gemini CLI verification: run /hooks list.")
return nil
}
func (p *GeminiPlugin) Uninstall(agentID string) error {
configDir, err := expandHome("~/.gemini")
document, err := readGeminiDocument()
if err != nil {
return err
}
configPath := filepath.Join(configDir, "settings.json")
settings, err := readJSONFile(configPath)
rendered, err := renderGeminiDocument(document, agentID, nil)
if err != nil {
return fmt.Errorf("render Gemini CLI user settings: %w", err)
}
change, err := prepareRenderedGeminiChange(rendered)
if err != nil {
return err
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
fmt.Println("No Gemini CLI hooks found.")
return nil
}
// Remove BeforeTool Elydora entries
beforeTool, _ := hooks["BeforeTool"].([]interface{})
var preFiltered []interface{}
for _, entry := range beforeTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
if len(preFiltered) == 0 {
delete(hooks, "BeforeTool")
} else {
hooks["BeforeTool"] = preFiltered
}
// Remove AfterTool Elydora entries
afterTool, _ := hooks["AfterTool"].([]interface{})
var postFiltered []interface{}
for _, entry := range afterTool {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
postFiltered = append(postFiltered, entry)
}
if len(postFiltered) == 0 {
delete(hooks, "AfterTool")
} else {
hooks["AfterTool"] = postFiltered
}
if len(hooks) == 0 {
delete(settings, "hooks")
} else {
settings["hooks"] = hooks
}
if err := writeJSONFile(configPath, settings); err != nil {
return err
}
if agentID != "" {
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Gemini CLI.")
return nil
return writeGeminiChanges(
[]*fileChange{change},
"Uninstall Gemini CLI hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
)
}
func (p *GeminiPlugin) Status() (PluginStatus, error) {
configDir, err := expandHome("~/.gemini")
if err != nil {
return PluginStatus{}, err
}
configPath := filepath.Join(configDir, "settings.json")
configPath, pathErr := geminiSettingsPath()
entry := SupportedAgents[geminiAgentKey]
status := PluginStatus{
AgentName: "gemini",
DisplayName: "Gemini CLI",
ConfigPath: configPath,
AgentName: geminiAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
settings, err := readJSONFile(configPath)
if pathErr != nil {
return status, pathErr
}
document, err := readGeminiDocument()
if err != nil {
return status, err
}
if !managedGeminiHooksEnabled(document.hookControls) {
return status, nil
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks != nil {
preConfigured := hasElydoraEntry(hooks["BeforeTool"])
postConfigured := hasElydoraEntry(hooks["AfterTool"])
status.HookConfigured = preConfigured && postConfigured
// Extract hook script path from the configured command
scriptPath := extractElydoraScriptPath(hooks["AfterTool"])
if scriptPath != "" {
if _, err := os.Stat(scriptPath); err == nil {
status.HookScriptExists = true
}
}
contracts, err := geminiRuntimeContracts(document.hooks)
if err != nil {
return status, err
}
status.Installed = status.HookConfigured && status.HookScriptExists
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookScriptExists, err = geminiRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
@@ -0,0 +1,111 @@
package plugins
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func geminiRunCLI(
t *testing.T,
binary string,
fixture *geminiFixture,
args ...string,
) (string, string, int) {
t.Helper()
process := exec.Command(binary, args...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"GEMINI_CLI_HOME="+fixture.geminiHome,
)
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 stdout.String(), stderr.String(), -1
}
func TestGeminiCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
repository := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
binary := filepath.Join(t.TempDir(), "elydora")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
build.Dir = repository
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
privateKeyFile := filepath.Join(t.TempDir(), "private.key")
tokenFile := filepath.Join(t.TempDir(), "api.token")
if err := os.WriteFile(privateKeyFile, []byte(geminiPrivateKey+"\n"), 0600); err != nil {
t.Fatalf("write private key input: %v", err)
}
if err := os.WriteFile(tokenFile, []byte("token-1\n"), 0600); err != nil {
t.Fatalf("write token input: %v", err)
}
stdout, stderr, exit := geminiRunCLI(
t,
binary,
fixture,
"install",
"--agent", geminiAgentKey,
"--org-id", "org-1",
"--agent-id", geminiTestAgentID,
"--private-key-file", privateKeyFile,
"--token-file", tokenFile,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "/hooks list") {
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
}
requireStrictGeminiPair(t, readGeminiTestObject(t, fixture.settingsPath))
stdout, stderr, exit = geminiRunCLI(
t,
binary,
fixture,
"status",
"--agent", geminiAgentKey,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = geminiRunCLI(
t,
binary,
fixture,
"uninstall",
"--agent", geminiAgentKey,
"--agent-id", geminiTestAgentID,
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed settings remain after CLI uninstall: %v", err)
}
if _, err := os.Lstat(fixture.agentDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime remains after CLI uninstall: %v", err)
}
}
@@ -0,0 +1,132 @@
package plugins
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
type geminiRuntimeReference struct {
agentID string
scriptPath string
}
func sameGeminiPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func isGeminiNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf(
"Gemini CLI hook commands require absolute runtime and script paths",
)
}
if runtime.GOOS != "windows" {
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
}
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
return "& " + quoteCodexPowerShell(codexPowerShellPath()) +
" -NoLogo -NoProfile -NonInteractive -EncodedCommand " +
encodeCodexPowerShell(source), nil
}
func parseGeminiWindowsCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
powerShell, next, ok := readCodexPowerShellArgument(command, 2)
if !ok || !isAbsoluteWindowsPath(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") {
return "", "", false
}
prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
if !strings.HasPrefix(command[next:], prefix) {
return "", "", false
}
source, ok := decodeCodexPowerShell(command[next+len(prefix):])
if !ok {
return "", "", false
}
return parseCodexPowerShellSource(source)
}
func parseGeminiCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
return runtimePath, scriptPath, true
}
return parseGeminiWindowsCommand(command)
}
func parseLegacyGeminiCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") {
return "", "", false
}
scriptPath := strings.TrimPrefix(command, "node ")
if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath {
return "", "", false
}
return "node", scriptPath, true
}
func geminiRuntimeReferenceForCommand(
command string,
scriptName string,
includeLegacy bool,
) (*geminiRuntimeReference, error) {
runtimePath, scriptPath, current := parseGeminiCommand(command)
legacy := false
if !current && includeLegacy {
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
}
validRuntime := (current && filepath.IsAbs(runtimePath) &&
isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node")
if !validRuntime || !filepath.IsAbs(scriptPath) ||
!sameGeminiFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
agentDirectory := filepath.Dir(scriptPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
}
@@ -0,0 +1,229 @@
package plugins
import (
"bytes"
"fmt"
"reflect"
"sort"
)
type geminiDocument struct {
filePath string
exists bool
raw []byte
root map[string]any
hooks geminiHooks
hookControls geminiHookControls
hasHooksContainer bool
ownedFile bool
}
type geminiRenderedDocument struct {
document *geminiDocument
changed bool
next []byte
remove bool
}
func geminiDocumentLabel(filePath string) string {
return fmt.Sprintf("Gemini CLI user settings at %s", filePath)
}
func parseGeminiDocument(
exists bool,
filePath string,
raw []byte,
) (*geminiDocument, error) {
label := geminiDocumentLabel(filePath)
root, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, err
}
hooks, err := readGeminiHooks(root)
if err != nil {
return nil, err
}
controls, err := readGeminiHookControls(root)
if err != nil {
return nil, err
}
_, hasHooks := root["hooks"]
return &geminiDocument{
filePath: filePath, exists: exists, raw: append([]byte(nil), raw...),
root: root, hooks: hooks, hookControls: controls,
hasHooksContainer: hasHooks,
ownedFile: bytes.HasPrefix(raw, []byte(geminiOwnedFileMarker)),
}, nil
}
func createGeminiDocument(filePath string) (*geminiDocument, error) {
return parseGeminiDocument(
false,
filePath,
[]byte(geminiOwnedFileMarker+"\n{}\n"),
)
}
func geminiAlreadyInstalled(
document *geminiDocument,
additions map[string]map[string]any,
) (bool, error) {
if len(additions) != len(geminiManagedEvents) {
return false, nil
}
removals, err := managedGeminiRemovals(document.hooks, "")
if err != nil {
return false, err
}
for _, event := range geminiManagedEvents {
expected, exists := additions[event]
if !exists {
return false, nil
}
removalCount := 0
removeGroup := false
for _, removal := range removals {
if removal.event == event {
removalCount++
removeGroup = removal.removeGroup
}
}
exactCount := 0
for _, group := range document.hooks[event] {
if reflect.DeepEqual(group, expected) {
exactCount++
}
}
if removalCount != 1 || !removeGroup || exactCount != 1 {
return false, nil
}
}
return true, nil
}
func renderGeminiDocument(
document *geminiDocument,
agentID string,
additions map[string]map[string]any,
) (*geminiRenderedDocument, error) {
if agentID == "" && len(additions) > 0 {
installed, err := geminiAlreadyInstalled(document, additions)
if err != nil {
return nil, err
}
if installed {
return &geminiRenderedDocument{document: document}, nil
}
}
label := geminiDocumentLabel(document.filePath)
editor, err := newJSONCEditor(document.raw, label, false)
if err != nil {
return nil, err
}
removals, err := managedGeminiRemovals(document.hooks, agentID)
if err != nil {
return nil, err
}
for _, event := range geminiManagedEvents {
eventRemovals := make([]geminiManagedRemoval, 0)
for _, removal := range removals {
if removal.event == event {
eventRemovals = append(eventRemovals, removal)
}
}
sort.Slice(eventRemovals, func(left, right int) bool {
return eventRemovals[left].groupIndex > eventRemovals[right].groupIndex
})
for _, removal := range eventRemovals {
groupPath := []any{"hooks", event, removal.groupIndex}
if removal.removeGroup {
if err := editor.remove(groupPath); err != nil {
return nil, err
}
continue
}
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
for _, handlerIndex := range removal.handlerIndexes {
if err := editor.remove(
appendGeminiPath(groupPath, "hooks", handlerIndex),
); err != nil {
return nil, err
}
}
}
if len(eventRemovals) > 0 {
current, parseErr := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if parseErr != nil {
return nil, parseErr
}
if groups, exists := current.hooks[event]; exists && len(groups) == 0 {
if err := editor.remove([]any{"hooks", event}); err != nil {
return nil, err
}
}
}
}
current, err := parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
if current.hasHooksContainer && len(current.hooks) == 0 {
if err := editor.remove([]any{"hooks"}); err != nil {
return nil, err
}
}
for _, event := range geminiManagedEvents {
group, exists := additions[event]
if !exists {
continue
}
current, err = parseGeminiDocument(
document.exists,
document.filePath,
editor.pack(),
)
if err != nil {
return nil, err
}
_, eventExists := current.hooks[event]
switch {
case !current.hasHooksContainer:
err = editor.addProperty(nil, "hooks", map[string]any{event: []any{group}})
case eventExists:
err = editor.appendArray([]any{"hooks", event}, group)
default:
err = editor.addProperty([]any{"hooks"}, event, []any{group})
}
if err != nil {
return nil, err
}
}
next := editor.pack()
nextDocument, err := parseGeminiDocument(
document.exists,
document.filePath,
next,
)
if err != nil {
return nil, err
}
remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0
return &geminiRenderedDocument{
document: document, changed: remove || !bytes.Equal(next, document.raw),
next: next, remove: remove,
}, nil
}
func appendGeminiPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts))
path = append(path, base...)
return append(path, parts...)
}
@@ -0,0 +1,253 @@
package plugins
import (
"path/filepath"
"runtime"
"sort"
"strings"
)
const (
geminiAgentKey = "gemini"
geminiConfigFile = "settings.json"
geminiGuardScript = "guard.js"
geminiAuditScript = "hook.js"
geminiGuardHookName = "elydora-guard"
geminiAuditHookName = "elydora-audit"
geminiHookTimeout = float64(10_000)
geminiOwnedFileMarker = "// Managed by Elydora"
)
var geminiManagedEvents = [...]string{"BeforeTool", "AfterTool"}
type geminiManagedRemoval struct {
event string
groupIndex int
handlerIndexes []int
removeGroup bool
}
type geminiRuntimeContract struct {
agentID string
guardPath string
auditPath string
}
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
command, err := buildGeminiCommand(runtimePath, scriptPath)
if err != nil {
return nil, err
}
return map[string]any{
"hooks": []any{map[string]any{
"type": "command", "name": name, "command": command,
"timeout": geminiHookTimeout,
}},
}, nil
}
func exactManagedGeminiGroup(group map[string]any) bool {
_, hasHooks := group["hooks"]
return len(group) == 1 && hasHooks
}
func currentManagedGeminiReference(
handler map[string]any,
scriptName string,
hookName string,
) (*geminiRuntimeReference, error) {
if len(handler) != 4 || handler["type"] != "command" ||
handler["name"] != hookName || handler["timeout"] != geminiHookTimeout {
return nil, nil
}
command, ok := handler["command"].(string)
if !ok {
return nil, nil
}
return geminiRuntimeReferenceForCommand(command, scriptName, false)
}
func legacyManagedGeminiReference(
handler map[string]any,
scriptName string,
) (*geminiRuntimeReference, error) {
if len(handler) != 2 || handler["type"] != "command" {
return nil, nil
}
command, ok := handler["command"].(string)
if !ok {
return nil, nil
}
return geminiRuntimeReferenceForCommand(command, scriptName, true)
}
func managedGeminiReference(
handler map[string]any,
scriptName string,
hookName string,
includeLegacy bool,
) (*geminiRuntimeReference, error) {
reference, err := currentManagedGeminiReference(handler, scriptName, hookName)
if err != nil || reference != nil || !includeLegacy {
return reference, err
}
return legacyManagedGeminiReference(handler, scriptName)
}
func managedGeminiHooksEnabled(controls geminiHookControls) bool {
if !controls.enabled {
return false
}
for _, entry := range controls.disabled {
if entry == geminiGuardHookName || entry == geminiAuditHookName {
return false
}
}
return true
}
func disabledManagedGeminiEntries(
controls geminiHookControls,
) ([]string, error) {
disabled := make([]string, 0)
for _, entry := range controls.disabled {
managed := entry == geminiGuardHookName || entry == geminiAuditHookName
for _, script := range []string{geminiGuardScript, geminiAuditScript} {
reference, err := geminiRuntimeReferenceForCommand(entry, script, true)
if err != nil {
return nil, err
}
managed = managed || reference != nil
}
if managed {
disabled = append(disabled, entry)
}
}
return disabled, nil
}
func managedGeminiRemovals(
hooks geminiHooks,
agentID string,
) ([]geminiManagedRemoval, error) {
removals := make([]geminiManagedRemoval, 0)
for _, contract := range []struct{ event, script, name string }{
{"BeforeTool", geminiGuardScript, geminiGuardHookName},
{"AfterTool", geminiAuditScript, geminiAuditHookName},
} {
for groupIndex, groupValue := range hooks[contract.event] {
group := groupValue.(map[string]any)
handlers := group["hooks"].([]any)
indexes := make([]int, 0)
for handlerIndex, handlerValue := range handlers {
handler := handlerValue.(map[string]any)
reference, err := managedGeminiReference(
handler,
contract.script,
contract.name,
true,
)
if err != nil {
return nil, err
}
if reference != nil &&
(agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) {
indexes = append(indexes, handlerIndex)
}
}
if len(indexes) > 0 {
removals = append(removals, geminiManagedRemoval{
contract.event,
groupIndex,
indexes,
exactManagedGeminiGroup(group) && len(indexes) == len(handlers),
})
}
}
}
return removals, nil
}
func geminiReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func geminiReferencesForEvent(
groups []any,
scriptName string,
hookName string,
) (map[string][]geminiRuntimeReference, error) {
result := map[string][]geminiRuntimeReference{}
for _, groupValue := range groups {
group := groupValue.(map[string]any)
if !exactManagedGeminiGroup(group) {
continue
}
for _, handlerValue := range group["hooks"].([]any) {
reference, err := currentManagedGeminiReference(
handlerValue.(map[string]any),
scriptName,
hookName,
)
if err != nil {
return nil, err
}
if reference != nil {
key := geminiReferenceKey(reference.agentID)
result[key] = append(result[key], *reference)
}
}
}
return result, nil
}
func geminiRuntimeContracts(
hooks geminiHooks,
) ([]geminiRuntimeContract, error) {
guards, err := geminiReferencesForEvent(
hooks["BeforeTool"],
geminiGuardScript,
geminiGuardHookName,
)
if err != nil {
return nil, err
}
audits, err := geminiReferencesForEvent(
hooks["AfterTool"],
geminiAuditScript,
geminiAuditHookName,
)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(guards))
for key := range guards {
keys = append(keys, key)
}
sort.Strings(keys)
contracts := make([]geminiRuntimeContract, 0, len(keys))
for _, key := range keys {
guard := guards[key]
audit := audits[key]
if len(guard) != 1 || len(audit) != 1 {
continue
}
contracts = append(contracts, geminiRuntimeContract{
agentID: guard[0].agentID,
guardPath: guard[0].scriptPath,
auditPath: audit[0].scriptPath,
})
}
return contracts, nil
}
func geminiRuntimeRoot() (string, error) {
root, err := AgentRuntimeRoot()
if err != nil {
return "", err
}
return filepath.Clean(root), nil
}
@@ -0,0 +1,193 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type geminiRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateGeminiInstallConfig(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 != geminiAgentKey {
return fmt.Errorf(
"Gemini CLI installation requires agent name %s",
geminiAgentKey,
)
}
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 geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) {
if err := validateGeminiInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := geminiRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &geminiRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, geminiGuardScript),
auditPath: filepath.Join(agentDirectory, geminiAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameGeminiPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameGeminiPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightGeminiInstallation(
config InstallConfig,
document *geminiDocument,
) (*geminiRuntimePaths, string, error) {
if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf(
"Gemini CLI installation requires a user settings document",
)
}
paths, err := geminiAgentPaths(config)
if err != nil {
return nil, "", err
}
if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Gemini CLI hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil
}
func buildGeminiRuntimeConfig(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": geminiAgentKey,
}
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 prepareGeminiInstallationChanges(
config InstallConfig,
paths *geminiRuntimePaths,
rendered *geminiRenderedDocument,
) ([]*fileChange, error) {
runtimeConfig, err := buildGeminiRuntimeConfig(config)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(
geminiAgentKey,
config.AgentID,
"{}\n",
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(
geminiAgentKey,
config.AgentID,
"{}\n",
false,
true,
)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedGeminiChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+104
View File
@@ -0,0 +1,104 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
)
func geminiConfigurationDirectory() (string, error) {
configured := os.Getenv("GEMINI_CLI_HOME")
if configured == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
configured = home
}
return filepath.Join(configured, ".gemini"), nil
}
func geminiSettingsPath() (string, error) {
directory, err := geminiConfigurationDirectory()
if err != nil {
return "", err
}
return filepath.Join(directory, geminiConfigFile), nil
}
func readGeminiDocument() (*geminiDocument, error) {
filePath, err := geminiSettingsPath()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(filePath),
"Gemini CLI configuration directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
filePath,
"Gemini CLI user settings",
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
if snapshot == nil {
return createGeminiDocument(filePath)
}
return parseGeminiDocument(true, filePath, snapshot.contents)
}
func prepareRenderedGeminiChange(
rendered *geminiRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Gemini CLI user settings",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
func writeGeminiChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(
settingsDirectory,
"Gemini CLI configuration directory",
); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
@@ -0,0 +1,70 @@
package plugins
import (
"os"
"os/exec"
"strings"
"testing"
)
func runOfficialGemini(
t *testing.T,
fixture *geminiFixture,
entry string,
args ...string,
) (string, error) {
t.Helper()
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
process := exec.Command(nodePath, append([]string{entry}, args...)...)
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"GEMINI_CLI_HOME="+fixture.geminiHome,
"GEMINI_API_KEY=official-loader-test-key",
"GEMINI_TELEMETRY_ENABLED=false",
"OTEL_SDK_DISABLED=true",
)
output, runErr := process.CombinedOutput()
return string(output), runErr
}
func TestGeminiOfficialCLIReadsManagedPair(t *testing.T) {
entry := os.Getenv("ELYDORA_GEMINI_ENTRY")
if entry == "" {
t.Skip("ELYDORA_GEMINI_ENTRY is unset")
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
version, err := runOfficialGemini(t, fixture, entry, "--version")
if err != nil || !strings.Contains(version, "0.51.0") {
t.Fatalf("official Gemini version = %q, %v", version, err)
}
output, err := runOfficialGemini(
t,
fixture,
entry,
"--skip-trust",
"--list-extensions",
)
if err != nil {
t.Fatalf("load official Gemini settings: %v\n%s", err, output)
}
lower := strings.ToLower(output)
for _, failure := range []string{
"invalid settings",
"settings validation",
"failed to parse",
"hook configuration error",
} {
if strings.Contains(lower, failure) {
t.Fatalf("official Gemini rejected settings: %s", output)
}
}
}
@@ -0,0 +1,224 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func installGeminiRuntimeFixture(
t *testing.T,
status string,
) (*geminiFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Gemini hooks: %v", err)
}
return fixture, api, server
}
func TestGeminiGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) {
fixture, api, server := installGeminiRuntimeFixture(t, "active")
defer server.Close()
settings := readGeminiTestObject(t, fixture.settingsPath)
guard := geminiTestManagedHandler(
t,
settings,
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
audit := geminiTestManagedHandler(
t,
settings,
"AfterTool",
geminiAuditScript,
geminiAuditHookName,
)
guardPayload := geminiOfficialPayload(fixture, "BeforeTool")
if exit, stdout, stderr := runGeminiCommand(
t,
guard["command"].(string),
fixture,
guardPayload,
); exit != 0 || stdout != "{}\n" || stderr != "" {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
firstPayload := geminiOfficialPayload(fixture, "AfterTool")
firstPayload["mcp_context"] = map[string]any{
"server_name": "filesystem",
"tool_name": "write_file",
}
firstPayload["original_request_name"] = "write_file"
firstPayload["future_provider_field"] = map[string]any{"preserved": true}
secondPayload := geminiOfficialPayload(fixture, "AfterTool")
secondPayload["tool_response"] = map[string]any{
"output": "second",
"error": nil,
}
for _, payload := range []map[string]any{firstPayload, secondPayload} {
if exit, stdout, stderr := runGeminiCommand(
t,
audit["command"].(string),
fixture,
payload,
); exit != 0 || stdout != "{}\n" || stderr != "" {
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
}
}
getAuth, postAuth, operations := api.snapshot()
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
}
if len(operations) != 2 ||
!reflect.DeepEqual(operations[0]["payload"], firstPayload) ||
!reflect.DeepEqual(operations[1]["payload"], secondPayload) {
t.Fatalf("Gemini native operations = %#v", operations)
}
if operations[0]["prev_chain_hash"] !=
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
t.Fatalf("Gemini operation chain = %#v", operations)
}
}
func TestGeminiGuardUsesOfficialBlockingExitCode(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installGeminiRuntimeFixture(t, status)
defer server.Close()
guard := geminiTestManagedHandler(
t,
readGeminiTestObject(t, fixture.settingsPath),
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
exit, stdout, stderr := runGeminiCommand(
t,
guard["command"].(string),
fixture,
geminiOfficialPayload(fixture, "BeforeTool"),
)
if exit != 2 || stdout != "" ||
!strings.Contains(strings.ToLower(stderr), status) {
t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr)
}
})
}
}
func TestGeminiRuntimesKeepFailOpenErrorsObservable(t *testing.T) {
fixture, api, server := installGeminiRuntimeFixture(t, "active")
settings := readGeminiTestObject(t, fixture.settingsPath)
guardCommand := geminiTestManagedHandler(
t,
settings,
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)["command"].(string)
auditCommand := geminiTestManagedHandler(
t,
settings,
"AfterTool",
geminiAuditScript,
geminiAuditHookName,
)["command"].(string)
invalidExit, invalidOutput, invalidError := runGeminiRawCommand(
t,
guardCommand,
fixture,
[]byte("{ malformed"),
)
if invalidExit != 0 || invalidOutput != "{}\n" ||
!strings.Contains(invalidError, "invalid JSON") {
t.Fatalf(
"invalid guard result = %d, %q, %q",
invalidExit,
invalidOutput,
invalidError,
)
}
api.mu.Lock()
api.postStatus = http.StatusServiceUnavailable
api.mu.Unlock()
auditExit, auditOutput, auditError := runGeminiCommand(
t,
auditCommand,
fixture,
geminiOfficialPayload(fixture, "AfterTool"),
)
server.Close()
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if auditExit != 0 || auditOutput != "{}\n" || auditError != "" ||
err != nil || !strings.Contains(string(log), "HTTP 503") {
t.Fatalf(
"audit failure = %d, %q, %q, log=%q, err=%v",
auditExit,
auditOutput,
auditError,
log,
err,
)
}
}
func TestGeminiRuntimeArtifactsUsePrivateModesOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode bits are not authoritative on Windows")
}
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
for _, item := range []struct {
path string
mode os.FileMode
}{
{fixture.runtimeConfig, 0600},
{fixture.privateKey, 0600},
{fixture.guardPath, 0700},
{fixture.hookPath, 0700},
{fixture.settingsPath, 0600},
} {
info, err := os.Stat(item.path)
if err != nil || info.Mode().Perm() != item.mode {
t.Fatalf("mode for %s = %v, %v", item.path, info, err)
}
}
}
func TestGeminiRuntimeOutputIsValidJSON(t *testing.T) {
fixture, _, server := installGeminiRuntimeFixture(t, "active")
defer server.Close()
guard := geminiTestManagedHandler(
t,
readGeminiTestObject(t, fixture.settingsPath),
"BeforeTool",
geminiGuardScript,
geminiGuardHookName,
)
_, stdout, _ := runGeminiCommand(
t,
guard["command"].(string),
fixture,
geminiOfficialPayload(fixture, "BeforeTool"),
)
var response map[string]any
if err := json.Unmarshal([]byte(stdout), &response); err != nil || len(response) != 0 {
t.Fatalf("Gemini response = %q, %v", stdout, err)
}
}
@@ -0,0 +1,208 @@
package plugins
import (
"fmt"
"math"
)
var geminiKnownEvents = map[string]bool{
"BeforeTool": true, "AfterTool": true, "BeforeAgent": true,
"Notification": true, "AfterAgent": true, "SessionStart": true,
"SessionEnd": true, "PreCompress": true, "BeforeModel": true,
"AfterModel": true, "BeforeToolSelection": true,
}
type geminiHooks map[string][]any
type geminiHookControls struct {
enabled bool
disabled []string
}
func optionalGeminiString(value map[string]any, field, label string) error {
if item, exists := value[field]; exists {
if _, ok := item.(string); !ok {
return fmt.Errorf(`%s field %q must be a string`, label, field)
}
}
return nil
}
func validateGeminiHandler(
value any,
event string,
groupIndex int,
handlerIndex int,
) error {
label := fmt.Sprintf(
"Gemini CLI settings handler hooks.%s[%d].hooks[%d]",
event,
groupIndex,
handlerIndex,
)
handler, ok := value.(map[string]any)
if !ok || handler == nil {
return fmt.Errorf("%s must be an object", label)
}
if handler["type"] != "command" {
return fmt.Errorf(
`%s has unsupported type %q`,
label,
fmt.Sprint(handler["type"]),
)
}
for _, field := range []string{"name", "description", "source"} {
if err := optionalGeminiString(handler, field, label); err != nil {
return err
}
}
if timeout, exists := handler["timeout"]; exists {
number, ok := timeout.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
return fmt.Errorf(
"%s timeout must be a non-negative finite number",
label,
)
}
}
if environment, exists := handler["env"]; exists {
entries, ok := environment.(map[string]any)
if !ok {
return fmt.Errorf("%s env must map names to strings", label)
}
for _, entry := range entries {
if _, ok := entry.(string); !ok {
return fmt.Errorf("%s env must map names to strings", label)
}
}
}
command, ok := handler["command"].(string)
if !ok || command == "" {
return fmt.Errorf("%s requires a non-empty command", label)
}
return nil
}
func validateGeminiGroup(value any, event string, groupIndex int) error {
label := fmt.Sprintf(
"Gemini CLI settings group hooks.%s[%d]",
event,
groupIndex,
)
group, ok := value.(map[string]any)
if !ok || group == nil {
return fmt.Errorf("%s must be an object", label)
}
if matcher, exists := group["matcher"]; exists {
if _, ok := matcher.(string); !ok {
return fmt.Errorf("%s matcher must be a string", label)
}
}
if sequential, exists := group["sequential"]; exists {
if _, ok := sequential.(bool); !ok {
return fmt.Errorf("%s sequential must be a boolean", label)
}
}
handlers, ok := group["hooks"].([]any)
if !ok {
return fmt.Errorf("%s must contain a hooks array", label)
}
for handlerIndex, handler := range handlers {
if err := validateGeminiHandler(
handler,
event,
groupIndex,
handlerIndex,
); err != nil {
return err
}
}
return nil
}
func readGeminiHooks(root map[string]any) (geminiHooks, error) {
value, exists := root["hooks"]
if !exists {
return geminiHooks{}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`Gemini CLI settings field "hooks" must be an object`)
}
hooks := make(geminiHooks, len(object))
for event, groupsValue := range object {
groups, ok := groupsValue.([]any)
if !ok {
return nil, fmt.Errorf(
`Gemini CLI settings field "hooks.%s" must be an array`,
event,
)
}
if geminiKnownEvents[event] {
for groupIndex, group := range groups {
if err := validateGeminiGroup(group, event, groupIndex); err != nil {
return nil, err
}
}
}
hooks[event] = append([]any(nil), groups...)
}
return hooks, nil
}
func readGeminiHookControls(root map[string]any) (geminiHookControls, error) {
value, exists := root["hooksConfig"]
if !exists {
return geminiHookControls{enabled: true}, nil
}
object, ok := value.(map[string]any)
if !ok || object == nil {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig" must be an object`,
)
}
for field := range object {
if field != "enabled" && field != "disabled" && field != "notifications" {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig" contains unsupported field %q`,
field,
)
}
}
enabled := true
if value, exists := object["enabled"]; exists {
flag, ok := value.(bool)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.enabled" must be a boolean`,
)
}
enabled = flag
}
if value, exists := object["notifications"]; exists {
if _, ok := value.(bool); !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.notifications" must be a boolean`,
)
}
}
disabled := []string{}
if value, exists := object["disabled"]; exists {
items, ok := value.([]any)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`,
)
}
for _, item := range items {
entry, ok := item.(string)
if !ok {
return geminiHookControls{}, fmt.Errorf(
`Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`,
)
}
disabled = append(disabled, entry)
}
}
return geminiHookControls{enabled: enabled, disabled: disabled}, nil
}
@@ -0,0 +1,266 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGeminiRejectsLinkedSettingsBeforeRuntimeWrites(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), "settings-target.json")
source := []byte("{\"owner\":\"protected\"}\n")
if err := os.MkdirAll(filepath.Dir(fixture.settingsPath), 0700); err != nil {
t.Fatalf("create Gemini settings directory: %v", err)
}
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write settings target: %v", err)
}
geminiSymlinkOrSkip(t, target, fixture.settingsPath)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "not a physical file") {
t.Fatalf("install error = %v", err)
}
current, readErr := os.ReadFile(target)
if readErr != nil || string(current) != string(source) {
t.Fatalf("linked target changed: %q, %v", current, readErr)
}
assertNoGeminiRuntimeWrites(t, fixture)
}
func TestGeminiRejectsLinkedSettingsAndRuntimeDirectories(t *testing.T) {
for _, kind := range []string{"settings", "runtime"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), kind+"-target")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create symlink target: %v", err)
}
var linkPath string
if kind == "settings" {
linkPath = filepath.Dir(fixture.settingsPath)
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create settings parent: %v", err)
}
} else {
linkPath = filepath.Join(fixture.homeDir, ".elydora")
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create runtime parent: %v", err)
}
}
geminiSymlinkOrSkip(t, target, linkPath)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("install error = %v", err)
}
if kind == "runtime" {
if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after runtime rejection: %v", err)
}
} else {
assertNoGeminiRuntimeWrites(t, fixture)
}
})
}
}
func TestGeminiRejectsLinkedRuntimeArtifacts(t *testing.T) {
for _, name := range []string{
"config.json",
"private.key",
"guard.js",
"hook.js",
"chain-state.json",
"status-cache.json",
"error.log",
} {
t.Run(name, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
path := filepath.Join(fixture.agentDir, name)
target := filepath.Join(filepath.Dir(fixture.homeDir), "target-"+name)
source := []byte("protected")
if current, err := os.ReadFile(path); err == nil {
source = current
}
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write runtime target: %v", err)
}
_ = os.Remove(path)
geminiSymlinkOrSkip(t, target, path)
var err error
if name == "private.key" {
_, err = fixture.plugin.Status()
} else {
err = fixture.plugin.PreflightInstall(fixture.config)
}
if err == nil || !strings.Contains(err.Error(), "not a physical file") {
t.Fatalf("linked %s error = %v", name, err)
}
current, readErr := os.ReadFile(target)
if readErr != nil || string(current) != string(source) {
t.Fatalf("linked target changed: %q, %v", current, readErr)
}
})
}
}
func TestGeminiRejectsOrphanedAndMismatchedRuntimeIdentity(t *testing.T) {
for _, kind := range []string{"orphaned", "mismatched"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime: %v", err)
}
if kind == "orphaned" {
if err := os.WriteFile(
fixture.guardPath,
[]byte("orphaned\n"),
0700,
); err != nil {
t.Fatalf("write orphaned guard: %v", err)
}
} else {
if err := os.WriteFile(
fixture.runtimeConfig,
[]byte(`{"agent_name":"gemini","agent_id":"another-agent"}`),
0600,
); err != nil {
t.Fatalf("write mismatched config: %v", err)
}
}
err := fixture.plugin.Install(fixture.config)
want := "identity cannot be verified"
if kind == "mismatched" {
want = "identity does not match"
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("install error = %v, want %q", err, want)
}
if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after identity rejection: %v", err)
}
})
}
}
func TestGeminiValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
edit func(*geminiFixture)
want string
}{
{"agent name", func(f *geminiFixture) { f.config.AgentName = "codex" }, "requires agent name gemini"},
{"organization", func(f *geminiFixture) { f.config.OrgID = " " }, "organization ID is required"},
{"key ID", func(f *geminiFixture) { f.config.KID = "\t" }, "key ID is required"},
{"token", func(f *geminiFixture) { f.config.Token = " " }, "token must contain"},
{"private key", func(f *geminiFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"base URL", func(f *geminiFixture) {
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
}, "query parameters"},
{"guard path", func(f *geminiFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed Elydora agent directory"},
{"audit path", func(f *geminiFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed Elydora agent directory"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
testCase.edit(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("settings exist after input rejection: %v", err)
}
assertNoGeminiRuntimeWrites(t, fixture)
})
}
}
func TestGeminiStatusSurfacesInvalidRuntimeMetadata(t *testing.T) {
for _, testCase := range []struct{ name, source, want string }{
{"malformed", "{ malformed", "parse Elydora runtime config"},
{"unsupported", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"gemini","extra":true}`, "unsupported field"},
{"identity", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"gemini"}`, "identity does not match"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
if err := os.WriteFile(
fixture.runtimeConfig,
[]byte(testCase.source),
0600,
); err != nil {
t.Fatalf("replace runtime config: %v", err)
}
_, err := fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("status error = %v, want %q", err, testCase.want)
}
})
}
}
func TestGeminiStatusRequiresEveryRuntimeFile(t *testing.T) {
for _, name := range []string{"config.json", "private.key", "guard.js", "hook.js"} {
t.Run("missing "+name, func(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
if err := os.Remove(filepath.Join(fixture.agentDir, name)); err != nil {
t.Fatalf("remove %s: %v", name, err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured ||
status.HookScriptExists {
t.Fatalf("missing %s status = %#v, %v", name, status, err)
}
})
}
}
func TestGeminiOwnershipRequiresExactHandlerFields(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
guard := legacyGeminiCommand(fixture.guardPath)
audit := legacyGeminiCommand(fixture.hookPath)
writeGeminiTestObject(t, fixture.settingsPath, map[string]any{
"hooks": map[string]any{
"BeforeTool": []any{map[string]any{
"label": "shared",
"hooks": []any{map[string]any{
"type": "command", "command": guard,
}},
}},
"AfterTool": []any{map[string]any{
"hooks": []any{map[string]any{
"type": "command", "command": audit, "label": "user",
}},
}},
},
})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
if err := fixture.plugin.Uninstall(geminiTestAgentID); err != nil {
t.Fatalf("uninstall Gemini hooks: %v", err)
}
remaining := readGeminiTestObject(t, fixture.settingsPath)
hooks := requireObject(t, remaining["hooks"])
before := requireArray(t, hooks["BeforeTool"])
after := requireArray(t, hooks["AfterTool"])
if len(before) != 1 || requireObject(t, before[0])["label"] != "shared" ||
len(requireArray(t, requireObject(t, before[0])["hooks"])) != 0 ||
len(after) != 1 || len(requireArray(t, requireObject(t, after[0])["hooks"])) != 1 {
t.Fatalf("ownership lookalikes changed: %#v", hooks)
}
}
@@ -0,0 +1,231 @@
package plugins
import (
"fmt"
"path/filepath"
"sort"
"strings"
)
func readGeminiRuntimeConfig(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 requireGeminiRuntimeString(
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 validateGeminiRuntimeConfig(
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 := requireGeminiRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameGeminiAgentID(agentID, expectedAgentID) ||
config["agent_name"] != geminiAgentKey {
return fmt.Errorf(
"elydora runtime identity does not match Gemini CLI hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireGeminiRuntimeString(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 validateGeminiRuntimeIdentity(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 := readGeminiRuntimeConfig(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, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, geminiAuditScript), "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"] != geminiAgentKey ||
!sameGeminiAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Gemini CLI agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) {
runtimeRoot, err := geminiRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameGeminiPath(
contract.auditPath,
filepath.Join(agentDirectory, geminiAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readGeminiRuntimeConfig(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 := validateGeminiRuntimeConfig(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)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := geminiRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
}
+436
View File
@@ -0,0 +1,436 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestGeminiRegistryFactoryAndRuntimeOwnership(t *testing.T) {
entry := SupportedAgents[geminiAgentKey]
if entry.Name != "Gemini CLI" || entry.ConfigDir != "~/.gemini" ||
entry.ConfigFile != geminiConfigFile {
t.Fatalf("Gemini registry entry = %#v", entry)
}
plugin, ok := NewPlugin(geminiAgentKey).(*GeminiPlugin)
if !ok || !plugin.ManagesGuardRuntime() {
t.Fatalf("Gemini plugin = %#v", NewPlugin(geminiAgentKey))
}
}
func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
command, err := buildGeminiCommand(nodePath, fixture.guardPath)
if err != nil {
t.Fatalf("build Gemini command: %v", err)
}
executable, script, ok := parseGeminiCommand(command)
if !ok || !sameGeminiPath(executable, nodePath) ||
!sameGeminiPath(script, fixture.guardPath) {
t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok)
}
if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") ||
strings.Contains(command, "$GEMINI_CWD")) {
t.Fatalf("Windows Gemini command exposes expandable path text: %q", command)
}
if _, err := buildGeminiCommand("node", fixture.guardPath); err == nil {
t.Fatal("accepted a relative Gemini runtime path")
}
if _, err := buildGeminiCommand(nodePath, "guard.js"); err == nil {
t.Fatal("accepted a relative Gemini script path")
}
for _, invalid := range []string{
command + " --inspect",
command + "\nexit 0",
"node " + fixture.guardPath,
} {
if _, _, ok := parseGeminiCommand(invalid); ok {
t.Fatalf("accepted non-canonical command %q", invalid)
}
}
}
func TestGeminiInstallPreservesJSONCAndIsIdempotent(t *testing.T) {
existing := strings.Join([]string{
"{",
" // Keep this user preference.",
` "theme": "GitHub",`,
` "hooks": {`,
` "FutureEvent": [null],`,
` "BeforeTool": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]`,
" }",
"}",
"",
}, "\r\n")
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: &existing},
)
projectSettings := filepath.Join(
fixture.projectDir,
".gemini",
geminiConfigFile,
)
systemSettings := filepath.Join(filepath.Dir(fixture.homeDir), "system.json")
for path, source := range map[string]string{
projectSettings: `{ "owner": "project" }` + "\n",
systemSettings: `{ "owner": "system" }` + "\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create source directory: %v", err)
}
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
t.Fatalf("write integration source: %v", err)
}
}
t.Setenv("GEMINI_CLI_SYSTEM_SETTINGS_PATH", systemSettings)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
first, err := os.ReadFile(fixture.settingsPath)
if err != nil {
t.Fatalf("read first Gemini settings: %v", err)
}
if !strings.Contains(string(first), "Keep this user preference") ||
!strings.Contains(string(first), "\r\n") {
t.Fatalf("JSONC formatting changed: %q", first)
}
settings := readGeminiTestObject(t, fixture.settingsPath)
if settings["theme"] != "GitHub" || !reflect.DeepEqual(
requireObject(t, settings["hooks"])["FutureEvent"],
[]any{nil},
) {
t.Fatalf("user settings changed: %#v", settings)
}
beforeTool := requireArray(t, requireObject(t, settings["hooks"])["BeforeTool"])
if requireObject(t, requireArray(
t,
requireObject(t, beforeTool[0])["hooks"],
)[0])["command"] != "user-hook" {
t.Fatalf("user hook changed: %#v", beforeTool)
}
requireStrictGeminiPair(t, settings)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repeat Gemini install: %v", err)
}
second, err := os.ReadFile(fixture.settingsPath)
if err != nil || string(second) != string(first) {
t.Fatalf("repeat install changed settings: %v", err)
}
for path, want := range map[string]string{
projectSettings: `{ "owner": "project" }` + "\n",
systemSettings: `{ "owner": "system" }` + "\n",
} {
actual, readErr := os.ReadFile(path)
if readErr != nil || string(actual) != want {
t.Fatalf("integration source changed at %s: %q, %v", path, actual, readErr)
}
}
for _, path := range []string{
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
info, statErr := os.Lstat(path)
if statErr != nil || !info.Mode().IsRegular() {
t.Fatalf("managed runtime %s = %v, %v", path, info, statErr)
}
}
assertNoGeminiTransactionArtifacts(t, fixture.homeDir)
}
func TestGeminiHomeResolutionMatchesOfficialSemantics(t *testing.T) {
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{useDefaultHome: true},
)
want := filepath.Join(fixture.homeDir, ".gemini")
if actual, err := geminiConfigurationDirectory(); err != nil || actual != want {
t.Fatalf("empty override directory = %q, %v; want %q", actual, err, want)
}
previous, err := os.Getwd()
if err != nil {
t.Fatalf("read working directory: %v", err)
}
if err := os.Chdir(fixture.projectDir); err != nil {
t.Fatalf("change working directory: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(previous); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
for _, testCase := range []struct{ value, expected string }{
{"relative gemini", filepath.Join("relative gemini", ".gemini")},
{"~", filepath.Join("~", ".gemini")},
} {
t.Setenv("GEMINI_CLI_HOME", testCase.value)
actual, resolveErr := geminiConfigurationDirectory()
if resolveErr != nil || actual != testCase.expected {
t.Fatalf("override %q = %q, %v; want %q", testCase.value, actual, resolveErr, testCase.expected)
}
}
}
func TestGeminiInstallAppendsToExistingEmptyManagedEvents(t *testing.T) {
source := `{"hooks":{"BeforeTool":[],"AfterTool":[]}}`
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: &source},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks into empty events: %v", err)
}
settings := readGeminiTestObject(t, fixture.settingsPath)
requireStrictGeminiPair(t, settings)
hooks := requireObject(t, settings["hooks"])
if len(requireArray(t, hooks["BeforeTool"])) != 1 ||
len(requireArray(t, hooks["AfterTool"])) != 1 {
t.Fatalf("managed event groups = %#v", hooks)
}
}
func TestGeminiRejectsInvalidSettingsBeforeRuntimeWrites(t *testing.T) {
for _, testCase := range []struct{ name, raw, want string }{
{"syntax", "{ malformed", "parse Gemini CLI user settings"},
{"root", "[]", "must contain a JSON object"},
{"trailing comma", `{ "theme": true, }`, "trailing comma"},
{"duplicate", `{ "hooks": {}, "hooks": {} }`, "duplicate key"},
{"hooks", `{ "hooks": null }`, `field "hooks" must be an object`},
{"event", `{ "hooks": { "BeforeTool": null } }`, "must be an array"},
{"group", `{ "hooks": { "BeforeTool": [null] } }`, "must be an object"},
{"matcher", `{ "hooks": { "BeforeTool": [{ "matcher": 1, "hooks": [] }] } }`, "matcher must be a string"},
{"sequential", `{ "hooks": { "BeforeTool": [{ "sequential": 1, "hooks": [] }] } }`, "sequential must be a boolean"},
{"missing handlers", `{ "hooks": { "BeforeTool": [{}] } }`, "must contain a hooks array"},
{"handler", `{ "hooks": { "BeforeTool": [{ "hooks": [null] }] } }`, "must be an object"},
{"type", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "http" }] }] } }`, "unsupported type"},
{"command", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "" }] }] } }`, "non-empty command"},
{"timeout", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "timeout": -1 }] }] } }`, "non-negative finite number"},
{"environment", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "env": { "A": 1 } }] }] } }`, "env must map names to strings"},
{"controls", `{ "hooksConfig": null }`, `hooksConfig" must be an object`},
{"control field", `{ "hooksConfig": { "future": true } }`, "unsupported field"},
{"enabled", `{ "hooksConfig": { "enabled": "yes" } }`, "must be a boolean"},
{"disabled", `{ "hooksConfig": { "disabled": [1] } }`, "array of strings"},
{"notifications", `{ "hooksConfig": { "notifications": 1 } }`, "must be a boolean"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: geminiString(testCase.raw)},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
raw, readErr := os.ReadFile(fixture.settingsPath)
if readErr != nil || string(raw) != testCase.raw {
t.Fatalf("original settings changed: %q, %v", raw, readErr)
}
assertNoGeminiRuntimeWrites(t, fixture)
})
}
}
func TestGeminiRespectsOfficialHookControls(t *testing.T) {
for _, testCase := range []struct{ raw, want string }{
{`{"hooksConfig":{"enabled":false}}`, "hooksConfig.enabled"},
{`{"hooksConfig":{"disabled":["elydora-guard"]}}`, geminiGuardHookName},
{`{"hooksConfig":{"disabled":["elydora-audit"]}}`, geminiAuditHookName},
} {
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: geminiString(testCase.raw)},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("hook control error = %v, want %q", err, testCase.want)
}
assertNoGeminiRuntimeWrites(t, fixture)
}
legacy := prepareGeminiFixture(t, geminiFixtureOptions{})
writeGeminiTestObject(t, legacy.settingsPath, map[string]any{
"hooksConfig": map[string]any{
"disabled": []any{legacyGeminiCommand(legacy.guardPath)},
},
})
err := legacy.plugin.Install(legacy.config)
if err == nil || !strings.Contains(err.Error(), "hooksConfig.disabled") {
t.Fatalf("legacy disabled command error = %v", err)
}
assertNoGeminiRuntimeWrites(t, legacy)
}
func TestGeminiMigratesLegacyHandlersAndPreservesLookalikes(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
legacyGuard := legacyGeminiCommand(fixture.guardPath)
legacyAudit := legacyGeminiCommand(fixture.hookPath)
lookalike := legacyGuard + " --inspect"
writeGeminiTestObject(t, fixture.settingsPath, map[string]any{
"hooks": map[string]any{
"BeforeTool": []any{
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": legacyGuard}}},
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": lookalike}}},
},
"AfterTool": []any{
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": legacyAudit}}},
},
},
})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("migrate Gemini hooks: %v", err)
}
settings := readGeminiTestObject(t, fixture.settingsPath)
requireStrictGeminiPair(t, settings)
raw, _ := os.ReadFile(fixture.settingsPath)
if !strings.Contains(string(raw), "--inspect") {
t.Fatalf("ownership lookalike was removed: %s", raw)
}
hooks := requireObject(t, settings["hooks"])
groups := requireArray(t, hooks["BeforeTool"])
managed := requireObject(t, groups[len(groups)-1])
managed["hooks"] = append(
requireArray(t, managed["hooks"]),
map[string]any{"type": "command", "command": "user-command"},
)
writeGeminiTestObject(t, fixture.settingsPath, settings)
if err := fixture.plugin.Uninstall(geminiTestAgentID); err != nil {
t.Fatalf("uninstall Gemini hooks: %v", err)
}
remaining := readGeminiTestObject(t, fixture.settingsPath)
remainingHooks := requireObject(t, remaining["hooks"])
if _, exists := remainingHooks["AfterTool"]; exists {
t.Fatalf("managed audit hook remains: %#v", remainingHooks)
}
commands := []string{}
for _, groupValue := range requireArray(t, remainingHooks["BeforeTool"]) {
for _, handlerValue := range requireArray(
t,
requireObject(t, groupValue)["hooks"],
) {
commands = append(
commands,
requireObject(t, handlerValue)["command"].(string),
)
}
}
if !reflect.DeepEqual(commands, []string{lookalike, "user-command"}) {
t.Fatalf("remaining commands = %#v", commands)
}
}
func TestGeminiUninstallPreservesUserSettingsAndRemovesOwnedFile(t *testing.T) {
userSource := `{"theme":"GitHub","hooks":{"Notification":[]}}`
user := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: &userSource},
)
if err := user.plugin.Install(user.config); err != nil {
t.Fatalf("install user Gemini hooks: %v", err)
}
if err := user.plugin.Uninstall(geminiTestAgentID); err != nil {
t.Fatalf("uninstall user Gemini hooks: %v", err)
}
remaining := readGeminiTestObject(t, user.settingsPath)
if remaining["theme"] != "GitHub" || !reflect.DeepEqual(
requireObject(t, remaining["hooks"])["Notification"],
[]any{},
) {
t.Fatalf("user settings changed: %#v", remaining)
}
owned := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := owned.plugin.Install(owned.config); err != nil {
t.Fatalf("install owned Gemini hooks: %v", err)
}
if err := owned.plugin.Uninstall(geminiTestAgentID); err != nil {
t.Fatalf("uninstall owned Gemini hooks: %v", err)
}
if _, err := os.Lstat(owned.settingsPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("owned Gemini settings remain: %v", err)
}
}
func TestGeminiStatusRequiresExactPairControlsAndRuntimeIdentity(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
assertInstalled := func(want bool) {
t.Helper()
status, err := fixture.plugin.Status()
if err != nil || status.Installed != want {
t.Fatalf("Gemini status = %#v, %v; want installed %v", status, err, want)
}
}
assertInstalled(true)
settings := readGeminiTestObject(t, fixture.settingsPath)
hooks := requireObject(t, settings["hooks"])
hooks["AfterTool"] = append(
requireArray(t, hooks["AfterTool"]),
requireArray(t, hooks["AfterTool"])[0],
)
writeGeminiTestObject(t, fixture.settingsPath, settings)
assertInstalled(false)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair duplicate Gemini hooks: %v", err)
}
settings = readGeminiTestObject(t, fixture.settingsPath)
settings["hooksConfig"] = map[string]any{"disabled": []any{geminiAuditHookName}}
writeGeminiTestObject(t, fixture.settingsPath, settings)
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("disabled Gemini status = %#v, %v", status, err)
}
settings["hooksConfig"] = map[string]any{"disabled": []any{}}
writeGeminiTestObject(t, fixture.settingsPath, settings)
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "private key") {
t.Fatalf("invalid private key status error = %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Gemini runtime: %v", err)
}
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("missing guard status = %#v, %v", status, err)
}
}
func TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
fixture.config.Token = ""
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks without token: %v", err)
}
config := readGeminiTestObject(t, fixture.runtimeConfig)
if _, exists := config["token"]; exists {
t.Fatalf("empty optional token was persisted: %#v", config)
}
}
func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) {
if runtime.GOOS == "windows" {
if !sameGeminiAgentID("AGENT-1", "agent-1") {
t.Fatal("Windows Gemini agent IDs should compare case-insensitively")
}
return
}
if sameGeminiAgentID("AGENT-1", "agent-1") {
t.Fatal("POSIX Gemini agent IDs should preserve case")
}
}
@@ -0,0 +1,330 @@
package plugins
import (
"bytes"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
const (
geminiTestAgentID = "agent-1"
geminiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
)
type geminiFixtureOptions struct {
existingRaw *string
useDefaultHome bool
}
type geminiFixture struct {
plugin *GeminiPlugin
config InstallConfig
homeDir string
projectDir string
geminiHome string
agentDir string
settingsPath string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
func geminiString(value string) *string {
return &value
}
func prepareGeminiFixture(
t *testing.T,
options geminiFixtureOptions,
) *geminiFixture {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(
root,
"home with 'quote $GEMINI_CWD %GEMINI_CWD%",
)
projectDir := filepath.Join(root, "project with spaces")
geminiHome := filepath.Join(homeDir, "custom gemini home")
if options.useDefaultHome {
geminiHome = homeDir
}
settingsPath := filepath.Join(geminiHome, ".gemini", geminiConfigFile)
agentDir := filepath.Join(homeDir, ".elydora", geminiTestAgentID)
if err := os.MkdirAll(projectDir, 0700); err != nil {
t.Fatalf("create project directory: %v", err)
}
if options.existingRaw != nil {
if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil {
t.Fatalf("create Gemini settings directory: %v", err)
}
if err := os.WriteFile(
settingsPath,
[]byte(*options.existingRaw),
0600,
); err != nil {
t.Fatalf("write Gemini settings: %v", err)
}
}
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-fragment"))
if options.useDefaultHome {
t.Setenv("GEMINI_CLI_HOME", "")
} else {
t.Setenv("GEMINI_CLI_HOME", geminiHome)
}
guardPath := filepath.Join(agentDir, geminiGuardScript)
return &geminiFixture{
plugin: &GeminiPlugin{},
config: InstallConfig{
AgentName: geminiAgentKey, OrgID: "org-1",
AgentID: geminiTestAgentID, PrivateKey: geminiPrivateKey,
KID: "kid-1", Token: "token-1",
BaseURL: "http://127.0.0.1:9", GuardScriptPath: guardPath,
},
homeDir: homeDir, projectDir: projectDir, geminiHome: geminiHome,
agentDir: agentDir, settingsPath: settingsPath, guardPath: guardPath,
hookPath: filepath.Join(agentDir, geminiAuditScript),
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
}
}
func readGeminiTestObject(t *testing.T, path string) map[string]any {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
value, err := decodeJSONCObject(raw, "Gemini test settings", false)
if err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func writeGeminiTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal Gemini settings: %v", err)
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create Gemini settings directory: %v", err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatalf("write Gemini settings: %v", err)
}
}
func geminiTestManagedHandler(
t *testing.T,
settings map[string]any,
event string,
scriptName string,
hookName string,
) map[string]any {
t.Helper()
hooks := requireObject(t, settings["hooks"])
for _, groupValue := range requireArray(t, hooks[event]) {
group := requireObject(t, groupValue)
for _, handlerValue := range requireArray(t, group["hooks"]) {
handler := requireObject(t, handlerValue)
reference, err := currentManagedGeminiReference(
handler,
scriptName,
hookName,
)
if err != nil {
t.Fatalf("inspect managed %s handler: %v", event, err)
}
if reference != nil {
return handler
}
}
}
t.Fatalf("%s handler for %s not found", event, scriptName)
return nil
}
func requireStrictGeminiPair(t *testing.T, settings map[string]any) {
t.Helper()
hooks := requireObject(t, settings["hooks"])
for _, item := range []struct {
event, script, name string
}{
{"BeforeTool", geminiGuardScript, geminiGuardHookName},
{"AfterTool", geminiAuditScript, geminiAuditHookName},
} {
handler := geminiTestManagedHandler(
t,
settings,
item.event,
item.script,
item.name,
)
want := map[string]any{
"type": "command", "name": item.name,
"command": handler["command"], "timeout": float64(10_000),
}
if !reflect.DeepEqual(handler, want) {
t.Fatalf("%s handler = %#v", item.event, handler)
}
managed := 0
for _, groupValue := range requireArray(t, hooks[item.event]) {
group := requireObject(t, groupValue)
for _, handlerValue := range requireArray(t, group["hooks"]) {
reference, err := currentManagedGeminiReference(
requireObject(t, handlerValue),
item.script,
item.name,
)
if err != nil {
t.Fatalf("inspect %s contract: %v", item.event, err)
}
if reference != nil {
managed++
if len(group) != 1 || len(requireArray(t, group["hooks"])) != 1 {
t.Fatalf("%s managed group has extra fields: %#v", item.event, group)
}
}
}
}
if managed != 1 {
t.Fatalf("%s managed handler count = %d", item.event, managed)
}
command := handler["command"].(string)
if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") {
t.Fatalf("Windows Gemini command is not encoded PowerShell: %q", command)
}
}
}
func geminiOfficialPayload(fixture *geminiFixture, event string) map[string]any {
payload := map[string]any{
"session_id": "session-1", "transcript_path": filepath.Join(
fixture.projectDir,
"transcript.jsonl",
),
"cwd": fixture.projectDir, "hook_event_name": event,
"timestamp": "2026-07-19T00:00:00.000Z",
"tool_name": "run_shell_command",
"tool_input": map[string]any{"command": "echo test"},
}
if event == "AfterTool" {
payload["tool_response"] = map[string]any{"output": "test", "error": nil}
}
return payload
}
func runGeminiCommand(
t *testing.T,
command string,
fixture *geminiFixture,
payload map[string]any,
) (int, string, string) {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Gemini payload: %v", err)
}
return runGeminiRawCommand(t, command, fixture, encoded)
}
func runGeminiRawCommand(
t *testing.T,
command string,
fixture *geminiFixture,
encoded []byte,
) (int, string, string) {
t.Helper()
var process *exec.Cmd
if runtime.GOOS == "windows" {
process = exec.Command(
codexPowerShellPath(),
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
command+"; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
)
} else {
process = exec.Command("sh", "-c", command)
}
process.Dir = fixture.projectDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"GEMINI_CLI_HOME="+fixture.geminiHome,
"ELYDORA_HOOK_PATH=injected-command-fragment",
)
process.Stdin = bytes.NewReader(encoded)
var stdout, stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return 0, stdout.String(), stderr.String()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return exitError.ExitCode(), stdout.String(), stderr.String()
}
t.Fatalf("run Gemini hook command: %v", err)
return -1, stdout.String(), stderr.String()
}
func legacyGeminiCommand(scriptPath string) string {
return "node " + scriptPath
}
func assertNoGeminiRuntimeWrites(t *testing.T, fixture *geminiFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig,
fixture.privateKey,
fixture.guardPath,
fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoGeminiTransactionArtifacts(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
}
if strings.HasSuffix(entry.Name(), ".tmp") ||
strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Gemini fixture: %v", err)
}
}
func geminiSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
@@ -0,0 +1,222 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func snapshotGeminiFiles(t *testing.T, paths ...string) map[string][]byte {
t.Helper()
result := make(map[string][]byte, len(paths))
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read snapshot %s: %v", path, err)
}
result[path] = raw
}
return result
}
func requireGeminiSnapshot(t *testing.T, snapshot map[string][]byte) {
t.Helper()
for path, want := range snapshot {
actual, err := os.ReadFile(path)
if err != nil || string(actual) != string(want) {
t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err)
}
}
}
func prepareGeminiTestChanges(
t *testing.T,
fixture *geminiFixture,
document *geminiDocument,
) ([]*fileChange, *geminiRuntimePaths) {
t.Helper()
paths, nodePath, err := preflightGeminiInstallation(fixture.config, document)
if err != nil {
t.Fatalf("preflight Gemini installation: %v", err)
}
guard, err := buildGeminiGroup(
nodePath,
paths.guardPath,
geminiGuardHookName,
)
if err != nil {
t.Fatalf("build Gemini guard group: %v", err)
}
audit, err := buildGeminiGroup(
nodePath,
paths.auditPath,
geminiAuditHookName,
)
if err != nil {
t.Fatalf("build Gemini audit group: %v", err)
}
rendered, err := renderGeminiDocument(
document,
"",
map[string]map[string]any{
"BeforeTool": guard,
"AfterTool": audit,
},
)
if err != nil {
t.Fatalf("render Gemini document: %v", err)
}
changes, err := prepareGeminiInstallationChanges(
fixture.config,
paths,
rendered,
)
if err != nil {
t.Fatalf("prepare Gemini installation: %v", err)
}
return changes, paths
}
func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) {
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
settings := readGeminiTestObject(t, fixture.settingsPath)
settings["owner"] = "user"
delete(requireObject(t, settings["hooks"]), "AfterTool")
writeGeminiTestObject(t, fixture.settingsPath, settings)
staleRuntime := `{"org_id":"old","agent_id":"agent-1","kid":"old","base_url":"https://old.test","agent_name":"gemini"}`
for _, item := range []struct{ path, source string }{
{fixture.guardPath, "stale guard\n"},
{fixture.runtimeConfig, staleRuntime},
{fixture.privateKey, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
{fixture.hookPath, "stale audit\n"},
} {
if err := os.WriteFile(item.path, []byte(item.source), 0600); err != nil {
t.Fatalf("write stale runtime %s: %v", item.path, err)
}
}
paths := []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.settingsPath,
}
before := snapshotGeminiFiles(t, paths...)
fixture.config.OrgID = "org-updated"
fixture.config.Token = "token-updated"
fixture.plugin.rename = func(source, destination string) error {
if sameGeminiPath(destination, fixture.settingsPath) &&
strings.HasSuffix(source, ".tmp") {
return errors.New("injected Gemini settings failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Gemini settings failure") {
t.Fatalf("install error = %v", err)
}
requireGeminiSnapshot(t, before)
assertNoGeminiTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) {
source := `{"owner":"original"}`
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: &source},
)
document, err := readGeminiDocument()
if err != nil {
t.Fatalf("read Gemini document: %v", err)
}
changes, paths := prepareGeminiTestChanges(t, fixture, document)
concurrent := []byte(`{"owner":"concurrent"}`)
if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err)
}
err = writeGeminiChanges(
changes,
"Install Gemini CLI hooks",
nil,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.settingsPath)
if readErr != nil || string(actual) != string(concurrent) {
t.Fatalf("concurrent settings changed: %q, %v", actual, readErr)
}
assertNoGeminiRuntimeWrites(t, fixture)
assertNoGeminiTransactionArtifacts(t, fixture.homeDir)
}
func TestGeminiInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) {
source := []byte(`{"owner":"original"}`)
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: geminiString(string(source))},
)
mutated := false
fixture.plugin.rename = func(stagedPath, destination string) error {
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
mutated = true
external := fixture.settingsPath + ".external"
if err := os.Rename(fixture.settingsPath, external); err != nil {
return err
}
if err := os.WriteFile(fixture.settingsPath, source, 0600); err != nil {
return err
}
}
return os.Rename(stagedPath, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.settingsPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("replacement settings changed: %q, %v", actual, readErr)
}
assertNoGeminiRuntimeWrites(t, fixture)
assertNoGeminiTransactionArtifacts(t, fixture.homeDir)
}
func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
source := `{"owner":"user"}`
fixture := prepareGeminiFixture(
t,
geminiFixtureOptions{existingRaw: &source},
)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Gemini hooks: %v", err)
}
before, err := os.ReadFile(fixture.settingsPath)
if err != nil {
t.Fatalf("read installed Gemini settings: %v", err)
}
fixture.plugin.rename = func(source, destination string) error {
if sameGeminiPath(destination, fixture.settingsPath) &&
strings.HasSuffix(source, ".tmp") {
return errors.New("injected Gemini uninstall failure")
}
return os.Rename(source, destination)
}
err = fixture.plugin.Uninstall(geminiTestAgentID)
if err == nil || !strings.Contains(err.Error(), "injected Gemini uninstall failure") {
t.Fatalf("uninstall error = %v", err)
}
after, readErr := os.ReadFile(fixture.settingsPath)
if readErr != nil || string(after) != string(before) {
t.Fatalf("Gemini settings changed: %q, %v", after, readErr)
}
assertNoGeminiTransactionArtifacts(t, fixture.homeDir)
}