feat: add native Codex hook integration
This commit is contained in:
1 parent
758d129dbf
commit
dfddb02f66
10 files changed
+925
-84
No files matched your search
@@ -74,6 +74,8 @@ elydora install \
|
|||||||
--kid agent-456-key-v1
|
--kid agent-456-key-v1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
||||||
|
|
||||||
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
@@ -90,6 +92,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
|
|||||||
| Agent | Key |
|
| Agent | Key |
|
||||||
|-------|-----|
|
|-------|-----|
|
||||||
| Claude Code | `claudecode` |
|
| Claude Code | `claudecode` |
|
||||||
|
| OpenAI Codex | `codex` |
|
||||||
| Copilot CLI | `copilot` |
|
| Copilot CLI | `copilot` |
|
||||||
| Cursor | `cursor` |
|
| Cursor | `cursor` |
|
||||||
| Gemini CLI | `gemini` |
|
| Gemini CLI | `gemini` |
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CodexPlugin manages OpenAI Codex user lifecycle hooks.
|
||||||
|
type CodexPlugin struct{}
|
||||||
|
|
||||||
|
func (p *CodexPlugin) Install(config InstallConfig) error {
|
||||||
|
if config.AgentID == "" {
|
||||||
|
return fmt.Errorf("agent ID is required")
|
||||||
|
}
|
||||||
|
configPath, err := codexConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
settings = map[string]any{"description": codexOwnedDescription}
|
||||||
|
}
|
||||||
|
hooks, err := codexHooksObject(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preGroups, err := codexEventGroups(hooks, "PreToolUse")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
postGroups, err := codexEventGroups(hooks, "PostToolUse")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preGroups, _, err = withoutCodexHandlers(preGroups, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
postGroups, _, err = withoutCodexHandlers(postGroups, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.GuardScriptPath == "" {
|
||||||
|
return fmt.Errorf("guard script path is required")
|
||||||
|
}
|
||||||
|
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !guardExists {
|
||||||
|
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
||||||
|
}
|
||||||
|
hookPath, err := hookScriptPath(config.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if config.HookScript != "" {
|
||||||
|
hookPath = config.HookScript
|
||||||
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks["PreToolUse"] = append(preGroups, codexMatcherGroup(
|
||||||
|
codexHandler(nodePath, config.GuardScriptPath, codexGuardStatusMessage),
|
||||||
|
))
|
||||||
|
hooks["PostToolUse"] = append(postGroups, codexMatcherGroup(
|
||||||
|
codexHandler(nodePath, hookPath, codexAuditStatusMessage),
|
||||||
|
))
|
||||||
|
next := cloneCodexObject(settings)
|
||||||
|
next["hooks"] = hooks
|
||||||
|
|
||||||
|
runtimeConfig := config
|
||||||
|
runtimeConfig.AgentName = codexAgentKey
|
||||||
|
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
||||||
|
return fmt.Errorf("generate hook script: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeHookJSONObjectAtomic(configPath, next); err != nil {
|
||||||
|
return fmt.Errorf("write Codex hooks config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Codex: run /hooks to review and trust the Elydora hooks.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CodexPlugin) Uninstall(agentID string) error {
|
||||||
|
configPath, err := codexConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
|
||||||
|
if err != nil || !exists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hooks, err := codexHooksObject(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
preGroups, err := codexEventGroups(hooks, "PreToolUse")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
postGroups, err := codexEventGroups(hooks, "PostToolUse")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filteredPre, preChanged, err := withoutCodexHandlers(preGroups, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filteredPost, postChanged, err := withoutCodexHandlers(postGroups, agentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !preChanged && !postChanged {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hooks["PreToolUse"] = filteredPre
|
||||||
|
hooks["PostToolUse"] = filteredPost
|
||||||
|
if codexSettingsOwned(settings, hooks) {
|
||||||
|
return removeHookFile(configPath, "Codex hooks config")
|
||||||
|
}
|
||||||
|
next := cloneCodexObject(settings)
|
||||||
|
next["hooks"] = hooks
|
||||||
|
return writeHookJSONObjectAtomic(configPath, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CodexPlugin) Status() (PluginStatus, error) {
|
||||||
|
configPath, err := codexConfigPath()
|
||||||
|
status := PluginStatus{
|
||||||
|
AgentName: codexAgentKey,
|
||||||
|
DisplayName: "OpenAI Codex",
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
|
||||||
|
if err != nil || !exists {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
hooks, err := codexHooksObject(settings)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
guard, err := findCodexHandler(hooks, "PreToolUse", codexGuardStatusMessage)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
audit, err := findCodexHandler(hooks, "PostToolUse", codexAuditStatusMessage)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
if guard == nil || audit == nil {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
status.HookConfigured = true
|
||||||
|
status.HookScriptExists, err = codexRuntimeScriptsExist(guard, audit)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
status.Installed = status.HookScriptExists
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
codexAgentKey = "codex"
|
||||||
|
codexOwnedDescription = "Elydora audit and freeze enforcement"
|
||||||
|
codexGuardStatusMessage = "Checking Elydora agent state"
|
||||||
|
codexAuditStatusMessage = "Recording Elydora tool use"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cloneCodexObject(value map[string]any) map[string]any {
|
||||||
|
clone := make(map[string]any, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
clone[key] = item
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexHooksObject(settings map[string]any) (map[string]any, error) {
|
||||||
|
value, exists := settings["hooks"]
|
||||||
|
if !exists {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
hooks, ok := value.(map[string]any)
|
||||||
|
if !ok || hooks == nil {
|
||||||
|
return nil, fmt.Errorf(`Codex hooks config field "hooks" must be an object`)
|
||||||
|
}
|
||||||
|
return cloneCodexObject(hooks), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexEventGroups(hooks map[string]any, event string) ([]map[string]any, error) {
|
||||||
|
value, exists := hooks[event]
|
||||||
|
if !exists {
|
||||||
|
return []map[string]any{}, nil
|
||||||
|
}
|
||||||
|
groups, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
|
||||||
|
}
|
||||||
|
objects := make([]map[string]any, 0, len(groups))
|
||||||
|
for _, group := range groups {
|
||||||
|
object, ok := group.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
|
||||||
|
}
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexGroupHandlers(group map[string]any) ([]map[string]any, error) {
|
||||||
|
value, exists := group["hooks"]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||||
|
}
|
||||||
|
handlers, ok := value.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||||
|
}
|
||||||
|
objects := make([]map[string]any, 0, len(handlers))
|
||||||
|
for _, handler := range handlers {
|
||||||
|
object, ok := handler.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||||
|
}
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCodexPath(value string) string {
|
||||||
|
normalized := filepath.ToSlash(value)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.ToLower(normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func isManagedCodexCommand(command any, scriptName, agentID string) bool {
|
||||||
|
text, ok := command.(string)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
normalized := normalizeCodexPath(text)
|
||||||
|
if !strings.Contains(normalized, "/.elydora/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
suffix := "/" + normalizeCodexPath(scriptName)
|
||||||
|
if agentID == "" {
|
||||||
|
return codexCommandEndsWith(normalized, suffix)
|
||||||
|
}
|
||||||
|
expected := normalizeCodexPath(filepath.Join(".elydora", agentID, scriptName))
|
||||||
|
return codexCommandEndsWith(normalized, "/"+expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexCommandEndsWith(command, scriptPath string) bool {
|
||||||
|
trimmed := strings.TrimSpace(command)
|
||||||
|
return strings.HasSuffix(trimmed, scriptPath) ||
|
||||||
|
strings.HasSuffix(trimmed, scriptPath+`'`) ||
|
||||||
|
strings.HasSuffix(trimmed, scriptPath+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isElydoraCodexHandler(handler map[string]any, agentID string) bool {
|
||||||
|
var scriptName string
|
||||||
|
switch handler["statusMessage"] {
|
||||||
|
case codexGuardStatusMessage:
|
||||||
|
scriptName = "guard.js"
|
||||||
|
case codexAuditStatusMessage:
|
||||||
|
scriptName = "hook.js"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isManagedCodexCommand(handler["command"], scriptName, agentID) ||
|
||||||
|
isManagedCodexCommand(handler["commandWindows"], scriptName, agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutCodexHandlers(groups []map[string]any, agentID string) ([]map[string]any, bool, error) {
|
||||||
|
filteredGroups := make([]map[string]any, 0, len(groups))
|
||||||
|
changed := false
|
||||||
|
for _, group := range groups {
|
||||||
|
handlers, err := codexGroupHandlers(group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
filteredHandlers := make([]any, 0, len(handlers))
|
||||||
|
removed := false
|
||||||
|
for _, handler := range handlers {
|
||||||
|
if isElydoraCodexHandler(handler, agentID) {
|
||||||
|
changed = true
|
||||||
|
removed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredHandlers = append(filteredHandlers, handler)
|
||||||
|
}
|
||||||
|
if len(filteredHandlers) == 0 && removed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextGroup := cloneCodexObject(group)
|
||||||
|
nextGroup["hooks"] = filteredHandlers
|
||||||
|
filteredGroups = append(filteredGroups, nextGroup)
|
||||||
|
}
|
||||||
|
return filteredGroups, changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCodexHandler(hooks map[string]any, event, status string) (map[string]any, error) {
|
||||||
|
groups, err := codexEventGroups(hooks, event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
handlers, err := codexGroupHandlers(group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, handler := range handlers {
|
||||||
|
if handler["statusMessage"] == status && isElydoraCodexHandler(handler, "") {
|
||||||
|
return handler, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexSettingsOwned(settings, hooks map[string]any) bool {
|
||||||
|
for key := range settings {
|
||||||
|
if key != "description" && key != "hooks" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings["description"] != codexOwnedDescription {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for event, value := range hooks {
|
||||||
|
if event != "PreToolUse" && event != "PostToolUse" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
groups, ok := value.([]map[string]any)
|
||||||
|
if !ok || len(groups) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexCommandReferences(handler map[string]any, scriptPath string) bool {
|
||||||
|
expected := normalizeCodexPath(scriptPath)
|
||||||
|
for _, key := range []string{"command", "commandWindows"} {
|
||||||
|
command, ok := handler[key].(string)
|
||||||
|
if ok && codexCommandEndsWith(normalizeCodexPath(command), expected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexRuntimeScriptsExist(guard, audit map[string]any) (bool, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
root := filepath.Join(home, ".elydora")
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agentDir := filepath.Join(root, entry.Name())
|
||||||
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
hookPath := filepath.Join(agentDir, "hook.js")
|
||||||
|
if !codexCommandReferences(guard, guardPath) || !codexCommandReferences(audit, hookPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDir, "config.json")
|
||||||
|
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agentName, ok := config["agent_name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf(`Elydora runtime config at %s field "agent_name" must be a string`, configPath)
|
||||||
|
}
|
||||||
|
if agentName != codexAgentKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hookExists, err := regularFileExists(hookPath, "Elydora audit runtime")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return guardExists && hookExists, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func codexConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".codex", "hooks.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "command",
|
||||||
|
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath),
|
||||||
|
"commandWindows": quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath),
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": statusMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexMatcherGroup(handler map[string]any) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": []any{handler},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
codexTestAgentID = "agent-1"
|
||||||
|
codexGuardStatus = "Checking Elydora agent state"
|
||||||
|
codexAuditStatus = "Recording Elydora tool use"
|
||||||
|
)
|
||||||
|
|
||||||
|
type codexFixture struct {
|
||||||
|
plugin *CodexPlugin
|
||||||
|
config InstallConfig
|
||||||
|
homeDir string
|
||||||
|
agentDir string
|
||||||
|
configPath string
|
||||||
|
guardPath string
|
||||||
|
hookPath string
|
||||||
|
runtimeConfig string
|
||||||
|
privateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareCodexFixture(t *testing.T, existingRaw string, createGuard bool) *codexFixture {
|
||||||
|
t.Helper()
|
||||||
|
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
||||||
|
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
|
||||||
|
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
|
||||||
|
guardPath := filepath.Join(agentDir, "guard.js")
|
||||||
|
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
||||||
|
t.Fatalf("create agent directory: %v", err)
|
||||||
|
}
|
||||||
|
if createGuard {
|
||||||
|
guard := "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
|
||||||
|
if err := os.WriteFile(guardPath, []byte(guard), 0600); err != nil {
|
||||||
|
t.Fatalf("write guard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existingRaw != "" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||||
|
t.Fatalf("create Codex config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, []byte(existingRaw), 0600); err != nil {
|
||||||
|
t.Fatalf("write Codex config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv("HOME", homeDir)
|
||||||
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
|
||||||
|
return &codexFixture{
|
||||||
|
plugin: &CodexPlugin{},
|
||||||
|
homeDir: homeDir,
|
||||||
|
agentDir: agentDir,
|
||||||
|
config: InstallConfig{
|
||||||
|
AgentName: "codex",
|
||||||
|
OrgID: "org-1",
|
||||||
|
AgentID: codexTestAgentID,
|
||||||
|
PrivateKey: "test-key",
|
||||||
|
KID: "kid-1",
|
||||||
|
BaseURL: "https://api.elydora.test",
|
||||||
|
GuardScriptPath: guardPath,
|
||||||
|
},
|
||||||
|
configPath: configPath,
|
||||||
|
guardPath: guardPath,
|
||||||
|
hookPath: filepath.Join(agentDir, "hook.js"),
|
||||||
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
|
privateKey: filepath.Join(agentDir, "private.key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCodexTestObject(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)
|
||||||
|
}
|
||||||
|
var value map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCodexHandler(t *testing.T, settings map[string]any, event, status 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)
|
||||||
|
if handler["statusMessage"] == status {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("handler %q not found", status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCodexCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal Codex payload: %v", err)
|
||||||
|
}
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
commandFile := filepath.Join(t.TempDir(), "run-codex-hook.cmd")
|
||||||
|
if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write hook command file: %v", err)
|
||||||
|
}
|
||||||
|
cmd = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("sh", "-c", command)
|
||||||
|
}
|
||||||
|
cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
||||||
|
cmd.Stdin = bytes.NewReader(encoded)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, stderr.String()
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
return exitError.ExitCode(), stderr.String()
|
||||||
|
}
|
||||||
|
t.Fatalf("run Codex hook command: %v", err)
|
||||||
|
return -1, stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexRegistryPointsAtGlobalHooksContract(t *testing.T) {
|
||||||
|
entry := SupportedAgents["codex"]
|
||||||
|
if entry.Name != "OpenAI Codex" || entry.ConfigDir != "~/.codex" || entry.ConfigFile != "hooks.json" {
|
||||||
|
t.Fatalf("Codex registry entry = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexInstallPreservesHooksAndIsIdempotent(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, `{
|
||||||
|
"description": "Workspace hooks",
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [{"hooks": [{"type": "command", "command": "existing-command"}]}],
|
||||||
|
"PreToolUse": [{"matcher": "Read", "hooks": []}]
|
||||||
|
}
|
||||||
|
}`, true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("repeat Codex install: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := readCodexTestObject(t, fixture.configPath)
|
||||||
|
if settings["description"] != "Workspace hooks" {
|
||||||
|
t.Fatalf("description changed: %#v", settings["description"])
|
||||||
|
}
|
||||||
|
hooks := requireObject(t, settings["hooks"])
|
||||||
|
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
||||||
|
t.Fatalf("managed hooks are not idempotent: %#v", hooks)
|
||||||
|
}
|
||||||
|
if len(requireArray(t, hooks["SessionStart"])) != 1 {
|
||||||
|
t.Fatalf("unrelated hooks changed: %#v", hooks)
|
||||||
|
}
|
||||||
|
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
|
||||||
|
if guard["type"] != "command" || guard["timeout"] != float64(10) {
|
||||||
|
t.Fatalf("unexpected guard handler: %#v", guard)
|
||||||
|
}
|
||||||
|
if _, ok := guard["command"].(string); !ok {
|
||||||
|
t.Fatalf("POSIX command = %#v, want string", guard["command"])
|
||||||
|
}
|
||||||
|
if _, ok := guard["commandWindows"].(string); !ok {
|
||||||
|
t.Fatalf("Windows command = %#v, want string", guard["commandWindows"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexCommandsBlockAndForwardOfficialPayload(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, "", true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||||
|
captureJSON, err := json.Marshal(capturePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal capture path: %v", err)
|
||||||
|
}
|
||||||
|
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
||||||
|
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||||
|
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n"
|
||||||
|
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0600); err != nil {
|
||||||
|
t.Fatalf("write capture hook: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := readCodexTestObject(t, fixture.configPath)
|
||||||
|
commandKey := "command"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
commandKey = "commandWindows"
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"hook_event_name": "PreToolUse",
|
||||||
|
"session_id": "session-1",
|
||||||
|
"turn_id": "turn-1",
|
||||||
|
"transcript_path": nil,
|
||||||
|
"cwd": fixture.homeDir,
|
||||||
|
"model": "gpt-5",
|
||||||
|
"permission_mode": "default",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_use_id": "call-1",
|
||||||
|
"tool_input": map[string]any{"command": "echo test"},
|
||||||
|
}
|
||||||
|
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
|
||||||
|
exitCode, stderr := runCodexCommand(t, guard[commandKey].(string), fixture.homeDir, payload)
|
||||||
|
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
||||||
|
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload["hook_event_name"] = "PostToolUse"
|
||||||
|
payload["tool_response"] = map[string]any{"output": "test"}
|
||||||
|
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
|
||||||
|
exitCode, stderr = runCodexCommand(t, audit[commandKey].(string), fixture.homeDir, payload)
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
captured := readCodexTestObject(t, capturePath)
|
||||||
|
if !reflect.DeepEqual(captured, payload) {
|
||||||
|
t.Fatalf("captured event = %#v, want %#v", captured, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStatusRequiresBothRuntimes(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, "", true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||||
|
t.Fatalf("installed status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(fixture.guardPath); err != nil {
|
||||||
|
t.Fatalf("remove guard: %v", err)
|
||||||
|
}
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||||
|
t.Fatalf("degraded status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexInstallRejectsMissingGuardBeforeWrites(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, "", false)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("write occurred at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexStatusSurfacesMalformedReferencedRuntimeMetadata(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, "", true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||||
|
t.Fatalf("status error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexInstallPreservesMatchingUserStatusText(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, `{"hooks":{
|
||||||
|
"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/guard.js.backup","statusMessage":"Checking Elydora agent state"}]}],
|
||||||
|
"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/hook.js.backup","statusMessage":"Recording Elydora tool use"}]}]
|
||||||
|
}}`, true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
hooks := requireObject(t, readCodexTestObject(t, fixture.configPath)["hooks"])
|
||||||
|
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 2 {
|
||||||
|
t.Fatalf("user handlers were removed: %#v", hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"existing-command"}]}]}}`, true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
settings := readCodexTestObject(t, fixture.configPath)
|
||||||
|
otherDir := filepath.Join(filepath.Dir(fixture.agentDir), "agent-10")
|
||||||
|
for _, contract := range []struct{ event, status string }{
|
||||||
|
{"PreToolUse", codexGuardStatus},
|
||||||
|
{"PostToolUse", codexAuditStatus},
|
||||||
|
} {
|
||||||
|
handler := requireCodexHandler(t, settings, contract.event, contract.status)
|
||||||
|
other := map[string]any{}
|
||||||
|
for key, value := range handler {
|
||||||
|
other[key] = value
|
||||||
|
}
|
||||||
|
for _, key := range []string{"command", "commandWindows"} {
|
||||||
|
other[key] = strings.ReplaceAll(other[key].(string), fixture.agentDir, otherDir)
|
||||||
|
}
|
||||||
|
hooks := requireObject(t, settings["hooks"])
|
||||||
|
hooks[contract.event] = append(requireArray(t, hooks[contract.event]), map[string]any{
|
||||||
|
"matcher": "*", "hooks": []any{other},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal augmented config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
|
||||||
|
t.Fatalf("write augmented config: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
remaining := readCodexTestObject(t, fixture.configPath)
|
||||||
|
hooks := requireObject(t, remaining["hooks"])
|
||||||
|
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
||||||
|
t.Fatalf("unexpected remaining hooks: %#v", hooks)
|
||||||
|
}
|
||||||
|
if !strings.Contains(requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string), otherDir) {
|
||||||
|
t.Fatalf("other agent handler was removed: %#v", hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexUninstallRemovesOwnedConfig(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, "", true)
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall Codex hooks: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("owned config still exists: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) {
|
||||||
|
for _, testCase := range []struct{ name, raw, want string }{
|
||||||
|
{"malformed", "{ malformed", "parse Codex hooks config"},
|
||||||
|
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
|
||||||
|
{"null-event", `{"hooks":{"PreToolUse":null}}`, `field "hooks.PreToolUse" must be an array`},
|
||||||
|
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "matcher group must contain a hooks array"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareCodexFixture(t, testCase.raw, true)
|
||||||
|
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.configPath)
|
||||||
|
if readErr != nil || string(raw) != testCase.raw {
|
||||||
|
t.Fatalf("original config changed: %q, %v", raw, readErr)
|
||||||
|
}
|
||||||
|
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readHookJSONObject(path, label string) (map[string]any, bool, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]any{}, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return nil, true, fmt.Errorf("parse %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok || object == nil {
|
||||||
|
return nil, true, fmt.Errorf("%s at %s must contain a JSON object", label, path)
|
||||||
|
}
|
||||||
|
return object, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHookJSONObjectAtomic(path string, value map[string]any) error {
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal %s: %w", path, err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
directory := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create directory %s: %w", directory, err)
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary file for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
cleanup := func(cause error) error {
|
||||||
|
closeErr := temporary.Close()
|
||||||
|
removeErr := os.Remove(temporaryPath)
|
||||||
|
if os.IsNotExist(removeErr) {
|
||||||
|
removeErr = nil
|
||||||
|
}
|
||||||
|
return errors.Join(cause, closeErr, removeErr)
|
||||||
|
}
|
||||||
|
if err := temporary.Chmod(0600); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("set permissions for %s: %w", temporaryPath, err))
|
||||||
|
}
|
||||||
|
if _, err := temporary.Write(encoded); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := temporary.Sync(); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("sync temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
return cleanup(fmt.Errorf("close temporary file for %s: %w", path, err))
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, path); err != nil {
|
||||||
|
removeErr := os.Remove(temporaryPath)
|
||||||
|
if os.IsNotExist(removeErr) {
|
||||||
|
removeErr = nil
|
||||||
|
}
|
||||||
|
return errors.Join(fmt.Errorf("replace %s: %w", path, err), removeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeHookFile(path, label string) error {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("remove %s at %s: %w", label, path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -24,11 +24,11 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v2Settings, _, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
v2Settings, _, err := readHookJSONObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v3Settings, _, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
v3Settings, _, err := readHookJSONObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,10 +109,10 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
|||||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
return fmt.Errorf("generate hook script: %w", err)
|
||||||
}
|
}
|
||||||
if err := writeKiroObjectAtomic(v2Path, nextV2); err != nil {
|
if err := writeHookJSONObjectAtomic(v2Path, nextV2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeKiroObjectAtomic(v3Path, nextV3); err != nil {
|
if err := writeHookJSONObjectAtomic(v3Path, nextV3); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +126,11 @@ func (p *KiroCliPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
v2Settings, v2Exists, err := readHookJSONObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
v3Settings, v3Exists, err := readHookJSONObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -164,11 +164,11 @@ func (p *KiroCliPlugin) Status() (PluginStatus, error) {
|
|||||||
DisplayName: "Kiro CLI",
|
DisplayName: "Kiro CLI",
|
||||||
ConfigPath: v3Path,
|
ConfigPath: v3Path,
|
||||||
}
|
}
|
||||||
v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config")
|
v2Settings, v2Exists, err := readHookJSONObject(v2Path, "Kiro CLI v2 agent config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config")
|
v3Settings, v3Exists, err := readHookJSONObject(v3Path, "Kiro CLI v3 hooks config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,9 +269,9 @@ func applyKiroMutation(mutation kiroConfigMutation) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if mutation.remove {
|
if mutation.remove {
|
||||||
return removeKiroFile(mutation.path, "Kiro CLI config")
|
return removeHookFile(mutation.path, "Kiro CLI config")
|
||||||
}
|
}
|
||||||
return writeKiroObjectAtomic(mutation.path, mutation.value)
|
return writeHookJSONObjectAtomic(mutation.path, mutation.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configuredKiroContracts(
|
func configuredKiroContracts(
|
||||||
@@ -378,7 +378,7 @@ func kiroRuntimeScriptsExist(contracts []kiroHookContract) (bool, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDir, "config.json")
|
configPath := filepath.Join(agentDir, "config.json")
|
||||||
config, exists, err := readKiroObject(configPath, "Elydora runtime config")
|
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -20,77 +18,6 @@ func kiroConfigPaths() (string, string, error) {
|
|||||||
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
|
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readKiroObject(path, label string) (map[string]any, bool, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return map[string]any{}, false, nil
|
|
||||||
}
|
|
||||||
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
var value any
|
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
|
||||||
return nil, true, fmt.Errorf("parse %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
object, ok := value.(map[string]any)
|
|
||||||
if !ok || object == nil {
|
|
||||||
return nil, true, fmt.Errorf("%s at %s must contain a JSON object", label, path)
|
|
||||||
}
|
|
||||||
return object, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeKiroObjectAtomic(path string, value map[string]any) error {
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal %s: %w", path, err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
directory := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
|
||||||
return fmt.Errorf("create directory %s: %w", directory, err)
|
|
||||||
}
|
|
||||||
temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temporary file for %s: %w", path, err)
|
|
||||||
}
|
|
||||||
temporaryPath := temporary.Name()
|
|
||||||
cleanup := func(cause error) error {
|
|
||||||
closeErr := temporary.Close()
|
|
||||||
removeErr := os.Remove(temporaryPath)
|
|
||||||
if os.IsNotExist(removeErr) {
|
|
||||||
removeErr = nil
|
|
||||||
}
|
|
||||||
return errors.Join(cause, closeErr, removeErr)
|
|
||||||
}
|
|
||||||
if err := temporary.Chmod(0600); err != nil {
|
|
||||||
return cleanup(fmt.Errorf("set permissions for %s: %w", temporaryPath, err))
|
|
||||||
}
|
|
||||||
if _, err := temporary.Write(encoded); err != nil {
|
|
||||||
return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err))
|
|
||||||
}
|
|
||||||
if err := temporary.Sync(); err != nil {
|
|
||||||
return cleanup(fmt.Errorf("sync temporary file for %s: %w", path, err))
|
|
||||||
}
|
|
||||||
if err := temporary.Close(); err != nil {
|
|
||||||
return cleanup(fmt.Errorf("close temporary file for %s: %w", path, err))
|
|
||||||
}
|
|
||||||
if err := os.Rename(temporaryPath, path); err != nil {
|
|
||||||
removeErr := os.Remove(temporaryPath)
|
|
||||||
if os.IsNotExist(removeErr) {
|
|
||||||
removeErr = nil
|
|
||||||
}
|
|
||||||
return errors.Join(fmt.Errorf("replace %s: %w", path, err), removeErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeKiroFile(path, label string) error {
|
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("remove %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveNodeRuntime() (string, error) {
|
func resolveNodeRuntime() (string, error) {
|
||||||
nodePath, err := exec.LookPath("node")
|
nodePath, err := exec.LookPath("node")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type AgentRegistryEntry struct {
|
|||||||
// SupportedAgents is the canonical registry of all supported agent integrations.
|
// SupportedAgents is the canonical registry of all supported agent integrations.
|
||||||
var SupportedAgents = map[string]AgentRegistryEntry{
|
var SupportedAgents = map[string]AgentRegistryEntry{
|
||||||
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
|
||||||
|
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
|
||||||
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
|
||||||
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
|
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
@@ -25,6 +26,8 @@ func NewPlugin(agentName string) AgentPlugin {
|
|||||||
switch agentName {
|
switch agentName {
|
||||||
case "claudecode":
|
case "claudecode":
|
||||||
return &ClaudeCodePlugin{}
|
return &ClaudeCodePlugin{}
|
||||||
|
case "codex":
|
||||||
|
return &CodexPlugin{}
|
||||||
case "cursor":
|
case "cursor":
|
||||||
return &CursorPlugin{}
|
return &CursorPlugin{}
|
||||||
case "gemini":
|
case "gemini":
|
||||||
|
|||||||
Reference in new issue
Block a user