fix(go): mirror native Gemini CLI hooks
This commit is contained in:
1 parent
2f223dca73
commit
2a4e62f0a3
16 files changed
+3129
-169
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user