From d44e1e03156a5f378c031601c52710b5aa130036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 05:51:25 -0400 Subject: [PATCH] fix: sync Go Codex hook integration --- integrations/catalog.json | 4 +- sdks/go/README.md | 3 + sdks/go/cmd/elydora/plugins/codex.go | 165 ++++++++ sdks/go/cmd/elydora/plugins/codex_contract.go | 252 ++++++++++++ sdks/go/cmd/elydora/plugins/codex_io.go | 32 ++ sdks/go/cmd/elydora/plugins/codex_test.go | 379 ++++++++++++++++++ sdks/go/cmd/elydora/plugins/hookconfig_io.go | 80 ++++ sdks/go/cmd/elydora/plugins/kirocli.go | 16 +- .../cmd/elydora/plugins/kirocli_contract.go | 6 +- sdks/go/cmd/elydora/plugins/kirocli_io.go | 73 ---- sdks/go/cmd/elydora/plugins/registry.go | 3 + 11 files changed, 927 insertions(+), 86 deletions(-) create mode 100644 sdks/go/cmd/elydora/plugins/codex.go create mode 100644 sdks/go/cmd/elydora/plugins/codex_contract.go create mode 100644 sdks/go/cmd/elydora/plugins/codex_io.go create mode 100644 sdks/go/cmd/elydora/plugins/codex_test.go create mode 100644 sdks/go/cmd/elydora/plugins/hookconfig_io.go diff --git a/integrations/catalog.json b/integrations/catalog.json index bd79074..8091483 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -109,9 +109,9 @@ "adapters": { "node": true, "python": true, - "go": false + "go": true }, - "delivery_state": "partial", + "delivery_state": "available", "source_url": "https://learn.chatgpt.com/docs/hooks" }, { diff --git a/sdks/go/README.md b/sdks/go/README.md index c3f6c92..52c40e7 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -71,6 +71,8 @@ elydora install \ --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`. ### Commands @@ -87,6 +89,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Agent | Key | |-------|-----| | Claude Code | `claudecode` | +| OpenAI Codex | `codex` | | Copilot CLI | `copilot` | | Cursor | `cursor` | | Gemini CLI | `gemini` | diff --git a/sdks/go/cmd/elydora/plugins/codex.go b/sdks/go/cmd/elydora/plugins/codex.go new file mode 100644 index 0000000..dbd9a67 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/codex.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/codex_contract.go b/sdks/go/cmd/elydora/plugins/codex_contract.go new file mode 100644 index 0000000..4546e35 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/codex_contract.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/codex_io.go b/sdks/go/cmd/elydora/plugins/codex_io.go new file mode 100644 index 0000000..913009c --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/codex_io.go @@ -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}, + } +} diff --git a/sdks/go/cmd/elydora/plugins/codex_test.go b/sdks/go/cmd/elydora/plugins/codex_test.go new file mode 100644 index 0000000..486c5eb --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/codex_test.go @@ -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) + } + } + }) + } +} diff --git a/sdks/go/cmd/elydora/plugins/hookconfig_io.go b/sdks/go/cmd/elydora/plugins/hookconfig_io.go new file mode 100644 index 0000000..2fa195c --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/hookconfig_io.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/kirocli.go b/sdks/go/cmd/elydora/plugins/kirocli.go index a8d0a86..d2c619e 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli.go +++ b/sdks/go/cmd/elydora/plugins/kirocli.go @@ -24,11 +24,11 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error { if err != nil { return err } - v2Settings, _, err := readKiroObject(v2Path, "Kiro CLI v2 agent config") + v2Settings, _, err := readHookJSONObject(v2Path, "Kiro CLI v2 agent config") if err != nil { return err } - v3Settings, _, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config") + v3Settings, _, err := readHookJSONObject(v3Path, "Kiro CLI v3 hooks config") if err != nil { return err } @@ -109,10 +109,10 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error { if err := GenerateHookScript(scriptPath, config); err != nil { return fmt.Errorf("generate hook script: %w", err) } - if err := writeKiroObjectAtomic(v2Path, nextV2); err != nil { + if err := writeHookJSONObjectAtomic(v2Path, nextV2); err != nil { return err } - if err := writeKiroObjectAtomic(v3Path, nextV3); err != nil { + if err := writeHookJSONObjectAtomic(v3Path, nextV3); err != nil { return err } @@ -126,11 +126,11 @@ func (p *KiroCliPlugin) Uninstall(agentID string) error { if err != nil { 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 { 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 { return err } @@ -164,11 +164,11 @@ func (p *KiroCliPlugin) Status() (PluginStatus, error) { DisplayName: "Kiro CLI", 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 { 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 { return status, err } diff --git a/sdks/go/cmd/elydora/plugins/kirocli_contract.go b/sdks/go/cmd/elydora/plugins/kirocli_contract.go index 63fc8e6..8dffe11 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli_contract.go +++ b/sdks/go/cmd/elydora/plugins/kirocli_contract.go @@ -269,9 +269,9 @@ func applyKiroMutation(mutation kiroConfigMutation) error { return nil } 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( @@ -378,7 +378,7 @@ func kiroRuntimeScriptsExist(contracts []kiroHookContract) (bool, error) { continue } 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 { return false, err } diff --git a/sdks/go/cmd/elydora/plugins/kirocli_io.go b/sdks/go/cmd/elydora/plugins/kirocli_io.go index c4294cb..4133857 100644 --- a/sdks/go/cmd/elydora/plugins/kirocli_io.go +++ b/sdks/go/cmd/elydora/plugins/kirocli_io.go @@ -1,8 +1,6 @@ package plugins import ( - "encoding/json" - "errors" "fmt" "os" "os/exec" @@ -20,77 +18,6 @@ func kiroConfigPaths() (string, string, error) { 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) { nodePath, err := exec.LookPath("node") if err != nil { diff --git a/sdks/go/cmd/elydora/plugins/registry.go b/sdks/go/cmd/elydora/plugins/registry.go index 7f38f5d..d215931 100644 --- a/sdks/go/cmd/elydora/plugins/registry.go +++ b/sdks/go/cmd/elydora/plugins/registry.go @@ -10,6 +10,7 @@ type AgentRegistryEntry struct { // SupportedAgents is the canonical registry of all supported agent integrations. var SupportedAgents = map[string]AgentRegistryEntry{ "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"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"}, @@ -25,6 +26,8 @@ func NewPlugin(agentName string) AgentPlugin { switch agentName { case "claudecode": return &ClaudeCodePlugin{} + case "codex": + return &CodexPlugin{} case "cursor": return &CursorPlugin{} case "gemini":