From 937bda3589ff4ff3a37c33810567abed4d21ab4f 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 11:52:08 -0400 Subject: [PATCH] feat: complete Factory Droid Go adapter delivery --- AGENTS.md | 2 +- integrations/catalog.json | 4 +- sdks/go/README.md | 5 +- sdks/go/cmd/elydora/plugins/droid.go | 215 +++++++++ sdks/go/cmd/elydora/plugins/droid_config.go | 300 +++++++++++++ sdks/go/cmd/elydora/plugins/droid_contract.go | 411 ++++++++++++++++++ sdks/go/cmd/elydora/plugins/droid_io.go | 329 ++++++++++++++ sdks/go/cmd/elydora/plugins/droid_jsonc.go | 259 +++++++++++ .../elydora/plugins/droid_recovery_test.go | 263 +++++++++++ sdks/go/cmd/elydora/plugins/droid_test.go | 263 +++++++++++ .../plugins/droid_test_helpers_test.go | 237 ++++++++++ sdks/go/cmd/elydora/plugins/registry.go | 3 + sdks/go/go.mod | 2 + sdks/go/go.sum | 4 + 14 files changed, 2293 insertions(+), 4 deletions(-) create mode 100644 sdks/go/cmd/elydora/plugins/droid.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_config.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_contract.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_io.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_jsonc.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_recovery_test.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_test.go create mode 100644 sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go diff --git a/AGENTS.md b/AGENTS.md index b781b19..fb10212 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This repository publishes the open-source server, console, integration catalog, - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline. - Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open. -- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits, use SDK-native absolute runtime commands, commit Python runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes. +- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes. ## Mirror Workflow diff --git a/integrations/catalog.json b/integrations/catalog.json index c5d82e6..4769704 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -186,8 +186,8 @@ "mechanism": "exit_code_2", "failure_mode": "fail_open" }, - "adapters": { "node": true, "python": true, "go": false }, - "delivery_state": "partial", + "adapters": { "node": true, "python": true, "go": true }, + "delivery_state": "available", "source_url": "https://docs.factory.ai/reference/hooks-reference", "contract_variants": [ { diff --git a/sdks/go/README.md b/sdks/go/README.md index 98326c4..c6c5912 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -8,7 +8,7 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat go get github.com/Elydora-Infrastructure/Elydora-Go-SDK ``` -Requires Go 1.21+. Zero third-party dependencies (stdlib only). +Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration. ## Quick Start @@ -83,6 +83,8 @@ Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora- Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory. +Factory Droid installation selects `PreToolUse` and `PostToolUse` independently across `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, and `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, commits runtime and hook changes as one transaction, and reports disabled hooks as unhealthy. Run `/hooks` in Droid after installation to review the effective hooks. + ### Commands | Command | Description | @@ -102,6 +104,7 @@ Auggie installation writes user-level hooks to `~/.augment/settings.json` and cr | Cline | `cline` | | Copilot CLI | `copilot` | | Cursor | `cursor` | +| Factory Droid | `droid` | | Gemini CLI | `gemini` | | Grok Build | `grok` | | Kiro CLI | `kirocli` | diff --git a/sdks/go/cmd/elydora/plugins/droid.go b/sdks/go/cmd/elydora/plugins/droid.go new file mode 100644 index 0000000..739bf41 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid.go @@ -0,0 +1,215 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type DroidPlugin struct { + rename droidRenameFunc +} + +type droidRuntimeConfig struct { + OrgID string `json:"org_id"` + AgentID string `json:"agent_id"` + KID string `json:"kid"` + BaseURL string `json:"base_url"` + Token string `json:"token"` + AgentName string `json:"agent_name"` +} + +func (p *DroidPlugin) Install(config InstallConfig) error { + runtimeRoot, agentDirectory, err := droidAgentDirectory(config.AgentID) + if err != nil { + return err + } + sources, err := readDroidSources() + if err != nil { + return err + } + settings := []droidHookSettings{sources.settings.hooks} + if sources.primary != nil { + settings = append(settings, sources.primary.hooks) + } + if err := validateDroidRegexes(settings...); err != nil { + return err + } + expectedGuard := filepath.Join(agentDirectory, droidGuardScript) + if !sameDroidPath(config.GuardScriptPath, expectedGuard) { + return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard) + } + 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) + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } + auditPath := filepath.Join(agentDirectory, droidAuditScript) + selection, err := selectDroidInstallationTargets(sources) + if err != nil { + return err + } + groups := map[string]map[string]any{ + "PreToolUse": buildDroidGroup(nodePath, config.GuardScriptPath), + "PostToolUse": buildDroidGroup(nodePath, auditPath), + } + documents := uniqueDroidDocuments( + sources.primary, + droidSettingsDocument(sources.settings), + selection.targets["PreToolUse"], + selection.targets["PostToolUse"], + ) + rendered := make([]*droidRenderedDocument, 0, len(documents)) + for _, document := range documents { + result, renderErr := renderDroidDocument( + document, "", runtimeRoot, droidAdditionsFor(document, selection.targets, groups), + ) + if renderErr != nil { + return renderErr + } + rendered = append(rendered, result) + } + changes, err := prepareDroidInstallationChanges(config, agentDirectory, auditPath, rendered) + if err != nil { + return err + } + if err := writeDroidChanges(changes, "Write Factory Droid installation", p.rename); err != nil { + return err + } + fmt.Printf( + " Factory Droid: PreToolUse: %s, PostToolUse: %s\n", + selection.targets["PreToolUse"].filePath, + selection.targets["PostToolUse"].filePath, + ) + fmt.Println(" Factory Droid: run /hooks to review the Elydora hook changes.") + return nil +} + +func (p *DroidPlugin) Uninstall(agentID string) error { + runtimeRoot, err := droidRuntimeRoot() + if err != nil { + return err + } + sources, err := readDroidSources() + if err != nil { + return err + } + documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings)) + changes := make([]*droidFileChange, 0, len(documents)) + for _, document := range documents { + rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil) + if renderErr != nil { + return renderErr + } + change, changeErr := prepareRenderedDroidChange(rendered) + if changeErr != nil { + return changeErr + } + changes = append(changes, change) + } + return writeDroidChanges(changes, "Write Factory Droid hook sources", p.rename) +} + +func (p *DroidPlugin) Status() (PluginStatus, error) { + entry := SupportedAgents[droidAgentKey] + status := PluginStatus{ + AgentName: droidAgentKey, DisplayName: entry.Name, + } + sources, err := readDroidSources() + if err != nil { + return status, err + } + status.ConfigPath = displayDroidConfigPath(sources) + primary := droidHookSettings{} + if sources.primary != nil { + primary = sources.primary.hooks + } + effective := mergeDroidSettings(primary, sources.settings.hooks) + if disabled, _ := effective["hooksDisabled"].(bool); disabled { + return status, nil + } + runtimeRoot, err := droidRuntimeRoot() + if err != nil { + return status, err + } + contracts := droidRuntimeContracts(effective, runtimeRoot) + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { + return status, nil + } + status.HookScriptExists, err = droidRuntimeFilesExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists + return status, nil +} + +func droidAgentDirectory(agentID string) (string, string, error) { + if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { + return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") + } + runtimeRoot, err := droidRuntimeRoot() + if err != nil { + return "", "", err + } + return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil +} + +func droidSettingsDocument(settings *droidDocument) *droidDocument { + if settings != nil && settings.hasHooksContainer { + return settings + } + return nil +} + +func prepareDroidInstallationChanges( + config InstallConfig, + agentDirectory, auditPath string, + rendered []*droidRenderedDocument, +) ([]*droidFileChange, error) { + baseURL := config.BaseURL + if baseURL == "" { + baseURL = "https://api.elydora.com" + } + runtimeConfig, err := json.MarshalIndent(droidRuntimeConfig{ + OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, + BaseURL: baseURL, Token: config.Token, AgentName: droidAgentKey, + }, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + runtimeConfig = append(runtimeConfig, '\n') + runtimeChanges := []struct { + path, label string + content []byte + mode os.FileMode + }{ + {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600}, + {auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700}, + } + changes := make([]*droidFileChange, 0, len(runtimeChanges)+len(rendered)) + for _, item := range runtimeChanges { + change, changeErr := prepareDroidFileChange(item.path, item.label, item.content, item.mode) + if changeErr != nil { + return nil, changeErr + } + changes = append(changes, change) + } + for _, document := range rendered { + change, changeErr := prepareRenderedDroidChange(document) + if changeErr != nil { + return nil, changeErr + } + changes = append(changes, change) + } + return changes, nil +} diff --git a/sdks/go/cmd/elydora/plugins/droid_config.go b/sdks/go/cmd/elydora/plugins/droid_config.go new file mode 100644 index 0000000..276b72d --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_config.go @@ -0,0 +1,300 @@ +package plugins + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "sort" +) + +type droidDocument struct { + kind string + filePath string + exists bool + raw []byte + hooks droidHookSettings + basePath []any + hasHooksContainer bool + ownedFile bool +} + +type droidSources struct { + rootPath string + primary *droidDocument + settings *droidDocument +} + +type droidInstallationTargets struct { + targets map[string]*droidDocument + createdRoot *droidDocument +} + +type droidRenderedDocument struct { + document *droidDocument + changed bool + next []byte + remove bool +} + +func droidFactoryPaths() (string, string, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", "", fmt.Errorf("resolve home directory: %w", err) + } + directory := filepath.Join(home, ".factory") + return filepath.Join(directory, "hooks.json"), + filepath.Join(directory, "hooks", "hooks.json"), + filepath.Join(directory, "settings.json"), nil +} + +func readDroidSources() (*droidSources, error) { + rootPath, legacyPath, settingsPath, err := droidFactoryPaths() + if err != nil { + return nil, err + } + rootRaw, rootExists, err := readDroidOptional(rootPath, "Factory Droid hooks") + if err != nil { + return nil, err + } + var primary *droidDocument + if rootExists { + primary, err = parseDroidDocument(true, rootPath, "hooks", rootRaw) + if err != nil { + return nil, err + } + } else { + legacyRaw, legacyExists, readErr := readDroidOptional(legacyPath, "Factory Droid legacy hooks") + if readErr != nil { + return nil, readErr + } + if legacyExists { + primary, err = parseDroidDocument(true, legacyPath, "legacy", legacyRaw) + if err != nil { + return nil, err + } + } + } + settingsRaw, settingsExists, err := readDroidOptional(settingsPath, "Factory Droid settings") + if err != nil { + return nil, err + } + var settings *droidDocument + if settingsExists { + settings, err = parseDroidDocument(true, settingsPath, "settings", settingsRaw) + } else { + settings, err = parseDroidDocument(false, settingsPath, "settings", []byte("{}\n")) + } + if err != nil { + return nil, err + } + return &droidSources{rootPath: rootPath, primary: primary, settings: settings}, nil +} + +func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidDocument, error) { + label := droidDocumentLabel(kind, filePath) + root, err := decodeDroidJSONCObject(raw, label) + if err != nil { + return nil, err + } + document := &droidDocument{ + kind: kind, filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), + } + if kind == "settings" { + hooksValue, hasHooks := root["hooks"] + document.basePath = []any{"hooks"} + document.hasHooksContainer = hasHooks + if !hasHooks { + document.hooks = droidHookSettings{} + return document, nil + } + document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`) + return document, err + } + document.hooks, err = readDroidHookSettings(root, label) + document.hasHooksContainer = true + document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)) + return document, err +} + +func droidDocumentLabel(kind, filePath string) string { + switch kind { + case "settings": + return fmt.Sprintf("Factory Droid settings at %s", filePath) + case "legacy": + return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath) + default: + return fmt.Sprintf("Factory Droid hooks at %s", filePath) + } +} + +func createOwnedDroidDocument(filePath string) (*droidDocument, error) { + return parseDroidDocument( + false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"), + ) +} + +func selectDroidInstallationTargets(sources *droidSources) (*droidInstallationTargets, error) { + selection := &droidInstallationTargets{targets: make(map[string]*droidDocument, len(droidToolEvents))} + for _, event := range droidToolEvents { + switch { + case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event): + selection.targets[event] = sources.primary + case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event): + selection.targets[event] = sources.settings + case sources.primary != nil: + selection.targets[event] = sources.primary + case sources.settings.hasHooksContainer: + selection.targets[event] = sources.settings + default: + if selection.createdRoot == nil { + created, err := createOwnedDroidDocument(sources.rootPath) + if err != nil { + return nil, err + } + selection.createdRoot = created + } + selection.targets[event] = selection.createdRoot + } + } + return selection, nil +} + +func hasDroidHookField(settings droidHookSettings, key string) bool { + _, exists := settings[key] + return exists +} + +func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument { + result := make([]*droidDocument, 0, len(documents)) + seen := map[string]bool{} + for _, document := range documents { + if document == nil { + continue + } + normalized := filepath.Clean(document.filePath) + if seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, document) + } + return result +} + +func droidAdditionsFor( + document *droidDocument, + targets map[string]*droidDocument, + groups map[string]map[string]any, +) map[string]map[string]any { + additions := map[string]map[string]any{} + for _, event := range droidToolEvents { + if sameDroidPath(targets[event].filePath, document.filePath) { + additions[event] = groups[event] + } + } + return additions +} + +func renderDroidDocument( + document *droidDocument, + agentID, runtimeRoot string, + additions map[string]map[string]any, +) (*droidRenderedDocument, error) { + editor, err := newDroidJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath)) + if err != nil { + return nil, err + } + removals := managedDroidRemovals(document.hooks, agentID, runtimeRoot) + sort.SliceStable(removals, func(left, right int) bool { + if removals[left].event == removals[right].event { + return removals[left].groupIndex > removals[right].groupIndex + } + return removals[left].event < removals[right].event + }) + for _, removal := range removals { + groupPath := appendDroidPath(document.basePath, removal.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(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil { + return nil, err + } + } + } + current, err := parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack()) + if err != nil { + return nil, err + } + if document.ownedFile { + for _, event := range droidToolEvents { + groups, exists := current.hooks[event].([]any) + if exists && len(groups) == 0 { + if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil { + return nil, err + } + } + } + } + for _, event := range droidToolEvents { + group, exists := additions[event] + if !exists { + continue + } + current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack()) + if err != nil { + return nil, err + } + eventPath := appendDroidPath(document.basePath, event) + if hasDroidHookField(current.hooks, event) { + err = editor.appendArray(eventPath, group) + } else { + err = editor.addProperty(document.basePath, event, []any{group}) + } + if err != nil { + return nil, err + } + } + next := editor.pack() + nextDocument, err := parseDroidDocument(document.exists, document.filePath, document.kind, next) + if err != nil { + return nil, err + } + remove := len(additions) == 0 && document.ownedFile && document.kind != "settings" && len(nextDocument.hooks) == 0 + return &droidRenderedDocument{ + document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove, + }, nil +} + +func appendDroidPath(base []any, parts ...any) []any { + path := make([]any, 0, len(base)+len(parts)) + path = append(path, base...) + return append(path, parts...) +} + +func displayDroidConfigPath(sources *droidSources) string { + if sources.primary != nil { + return sources.primary.filePath + } + if sources.settings.exists && sources.settings.hasHooksContainer { + return sources.settings.filePath + } + return sources.rootPath +} + +func readDroidOptional(path, label string) ([]byte, bool, error) { + raw, err := os.ReadFile(path) // #nosec G304 -- paths are fixed user-level Factory configuration paths. + if err == nil { + return raw, true, nil + } + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err) +} diff --git a/sdks/go/cmd/elydora/plugins/droid_contract.go b/sdks/go/cmd/elydora/plugins/droid_contract.go new file mode 100644 index 0000000..a9fd8cd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_contract.go @@ -0,0 +1,411 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + droidAgentKey = "droid" + droidGuardScript = "guard.js" + droidAuditScript = "hook.js" + droidHookTimeout = float64(10) + droidOwnedFileMarker = "// Managed by Elydora" + droidPOSIXApostrophe = `'"'"'` +) + +var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} + +var droidEventNames = map[string]bool{ + "PreToolUse": true, "PostToolUse": true, "Notification": true, + "UserPromptSubmit": true, "Stop": true, "SubagentStop": true, + "PreCompact": true, "SessionStart": true, "SessionEnd": true, +} + +var droidFlagNames = map[string]bool{"hooksDisabled": true, "showHookOutput": true} + +type droidHookSettings map[string]any + +type droidManagedRemoval struct { + event string + groupIndex int + handlerIndexes []int + removeGroup bool +} + +type droidRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +type droidRegexEntry struct { + Label string `json:"label"` + Pattern string `json:"pattern"` +} + +func readDroidHookSettings(value any, label string) (droidHookSettings, error) { + object, ok := value.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf("%s must contain a JSON object", label) + } + settings := make(droidHookSettings, len(object)) + for key, item := range object { + if droidFlagNames[key] { + if _, ok := item.(bool); !ok { + return nil, fmt.Errorf(`%s field %q must be a boolean`, label, key) + } + settings[key] = item + continue + } + if !droidEventNames[key] { + return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key) + } + groups, ok := item.([]any) + if !ok { + return nil, fmt.Errorf(`%s field %q must be an array`, label, key) + } + for groupIndex, group := range groups { + if err := validateDroidGroup(group, label, key, groupIndex); err != nil { + return nil, err + } + } + settings[key] = item + } + return settings, nil +} + +func validateDroidGroup(value any, label, event string, groupIndex int) error { + location := fmt.Sprintf(`%s field %q[%d]`, label, event, groupIndex) + group, ok := value.(map[string]any) + if !ok || group == nil { + return fmt.Errorf("%s must be an object", location) + } + if matcher, exists := group["matcher"]; exists { + if _, ok := matcher.(string); !ok { + return fmt.Errorf("%s matcher must be a string", location) + } + } + if expression, exists := group["commandRegex"]; exists { + if _, ok := expression.(string); !ok { + return fmt.Errorf("%s commandRegex must be a string", location) + } + } + handlers, ok := group["hooks"].([]any) + if !ok { + return fmt.Errorf("%s must contain a hooks array", location) + } + for handlerIndex, handler := range handlers { + if err := validateDroidHandler(handler, location, handlerIndex); err != nil { + return err + } + } + return nil +} + +func validateDroidHandler(value any, groupLabel string, handlerIndex int) error { + location := fmt.Sprintf("%s.hooks[%d]", groupLabel, handlerIndex) + handler, ok := value.(map[string]any) + if !ok || handler == nil { + return fmt.Errorf("%s must be an object", location) + } + if handler["type"] != "command" { + return fmt.Errorf(`%s type must be "command"`, location) + } + if _, ok := handler["command"].(string); !ok { + return fmt.Errorf("%s command must be a string", location) + } + if timeout, exists := handler["timeout"]; exists { + number, ok := timeout.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) { + return fmt.Errorf("%s timeout must be a finite number", location) + } + } + return nil +} + +func validateDroidRegexes(settings ...droidHookSettings) error { + entries := make([]droidRegexEntry, 0) + for _, source := range settings { + for event, value := range source { + if droidFlagNames[event] { + continue + } + for index, groupValue := range value.([]any) { + group := groupValue.(map[string]any) + if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" { + entries = append(entries, droidRegexEntry{ + Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] matcher`, event, index), Pattern: matcher, + }) + } + if expression, ok := group["commandRegex"].(string); ok { + entries = append(entries, droidRegexEntry{ + Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index), Pattern: expression, + }) + } + } + } + } + if len(entries) == 0 { + return nil + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return fmt.Errorf("Node.js runtime is required to validate Factory Droid hook expressions: %w", err) + } + payload, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("encode Factory Droid regular expressions: %w", err) + } + validator := `import fs from 'node:fs'; +const entries = JSON.parse(fs.readFileSync(0, 'utf8')); +for (const entry of entries) { + try { new RegExp(entry.pattern); } + catch (error) { + process.stderr.write(entry.label + ': ' + (error instanceof Error ? error.message : String(error))); + process.exit(1); + } +}` + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // #nosec G204 -- nodePath is resolved by exec.LookPath and every argument is fixed. + command := exec.CommandContext(ctx, nodePath, "--input-type=module", "--eval", validator) + command.Stdin = bytes.NewReader(payload) + output, runErr := command.CombinedOutput() + if ctx.Err() != nil { + return fmt.Errorf("Factory Droid regular expression validation timed out: %w", ctx.Err()) + } + if runErr != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = runErr.Error() + } + return fmt.Errorf("Factory Droid matcher must be a valid JavaScript regular expression: %s", message) + } + return nil +} + +func buildDroidCommand(nodePath, scriptPath string) string { + if runtime.GOOS == "windows" { + return quoteDroidWindowsArgument(nodePath) + " " + quoteDroidWindowsArgument(scriptPath) + } + return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) +} + +func quoteDroidWindowsArgument(value string) string { + quoted := quoteWindowsArgument(value) + if strings.HasPrefix(quoted, `"`) { + return quoted + } + return `"` + quoted + `"` +} + +func buildDroidGroup(nodePath, scriptPath string) map[string]any { + return map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout, + }}, + } +} + +func parseDroidCommand(command string) (string, string, bool) { + reader := readDroidPOSIXArgument + if runtime.GOOS == "windows" { + reader = readDroidWindowsArgument + } + executable, next, ok := reader(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := reader(command, next+1) + return executable, script, ok && end == len(command) && executable != "" && script != "" +} + +func readDroidWindowsArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '"' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); index++ { + if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' { + value.WriteByte('"') + index++ + continue + } + if command[index] == '"' { + return value.String(), index + 1, true + } + value.WriteByte(command[index]) + } + return "", start, false +} + +func readDroidPOSIXArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); { + if strings.HasPrefix(command[index:], droidPOSIXApostrophe) { + value.WriteByte('\'') + index += len(droidPOSIXApostrophe) + continue + } + if command[index] == '\'' { + return value.String(), index + 1, true + } + value.WriteByte(command[index]) + index++ + } + return "", start, false +} + +func sameDroidPath(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 sameDroidAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func droidRuntimeRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".elydora"), nil +} + +func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) { + if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout { + return "", false + } + command, ok := handler["command"].(string) + if !ok { + return "", false + } + executable, scriptPath, ok := parseDroidCommand(command) + if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || !isDroidNodeExecutable(executable) || + !sameDroidFileName(filepath.Base(scriptPath), scriptName) { + return "", false + } + agentDirectory := filepath.Dir(scriptPath) + if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) { + return "", false + } + agentID := filepath.Base(agentDirectory) + return agentID, agentID != "" && agentID != "." && agentID != ".." +} + +func isDroidNodeExecutable(path string) bool { + name := filepath.Base(path) + if runtime.GOOS == "windows" { + return strings.EqualFold(name, "node.exe") + } + return name == "node" +} + +func sameDroidFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval { + removals := make([]droidManagedRemoval, 0) + for _, contract := range []struct{ event, script string }{ + {"PreToolUse", droidGuardScript}, {"PostToolUse", droidAuditScript}, + } { + groups, _ := settings[contract.event].([]any) + for groupIndex, value := range groups { + group := value.(map[string]any) + handlers := group["hooks"].([]any) + indexes := make([]int, 0) + for handlerIndex, handlerValue := range handlers { + managedID, managed := managedDroidAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot) + if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) { + indexes = append(indexes, handlerIndex) + } + } + if len(indexes) == 0 { + continue + } + _, hasMatcher := group["matcher"] + _, hasHooks := group["hooks"] + exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) + removals = append(removals, droidManagedRemoval{contract.event, groupIndex, indexes, exactGroup}) + } + } + return removals +} + +func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract { + guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot) + audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot) + contracts := make([]droidRuntimeContract, 0) + for guardID := range guards { + matched := false + for auditID := range audits { + if sameDroidAgentID(guardID, auditID) { + matched = true + break + } + } + if matched { + root := filepath.Join(runtimeRoot, guardID) + contracts = append(contracts, droidRuntimeContract{ + guardID, filepath.Join(root, droidGuardScript), filepath.Join(root, droidAuditScript), + }) + } + } + return contracts +} + +func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot string) map[string]bool { + ids := map[string]bool{} + groups, _ := settings[event].([]any) + for _, groupValue := range groups { + for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) { + if agentID, ok := managedDroidAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok { + ids[agentID] = true + } + } + } + return ids +} + +func mergeDroidSettings(primary, fallback droidHookSettings) droidHookSettings { + merged := make(droidHookSettings, len(primary)+len(fallback)) + for key, value := range fallback { + merged[key] = value + } + for key, value := range primary { + merged[key] = value + } + return merged +} diff --git a/sdks/go/cmd/elydora/plugins/droid_io.go b/sdks/go/cmd/elydora/plugins/droid_io.go new file mode 100644 index 0000000..b97ef8b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_io.go @@ -0,0 +1,329 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +type droidRenameFunc func(source, destination string) error + +type droidFileChange struct { + filePath string + label string + original []byte + originalMode os.FileMode + existed bool + next []byte + mode os.FileMode + remove bool +} + +type droidStagedChange struct { + change droidFileChange + temporaryPath string + rollbackPath string + committed bool +} + +func prepareDroidFileChange( + filePath, label string, + next []byte, + mode os.FileMode, +) (*droidFileChange, error) { + original, exists, err := readDroidOptional(filePath, label) + if err != nil { + return nil, err + } + if exists && bytes.Equal(original, next) { + return nil, nil + } + if !exists && next == nil { + return nil, nil + } + originalMode := mode + if exists { + info, statErr := os.Stat(filePath) + if statErr != nil { + return nil, fmt.Errorf("read file mode for %s at %s: %w", label, filePath, statErr) + } + originalMode = info.Mode().Perm() + } + return &droidFileChange{ + filePath: filePath, label: label, original: original, originalMode: originalMode, + existed: exists, next: append([]byte(nil), next...), mode: mode, + }, nil +} + +func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*droidFileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + label := "Factory Droid hooks" + if rendered.document.kind == "legacy" { + label = "Factory Droid legacy hooks" + } else if rendered.document.kind == "settings" { + label = "Factory Droid settings" + } + originalMode := os.FileMode(0600) + var original []byte + if rendered.document.exists { + info, err := os.Stat(rendered.document.filePath) + if err != nil { + return nil, fmt.Errorf("read file mode for %s at %s: %w", label, rendered.document.filePath, err) + } + originalMode = info.Mode().Perm() + original = append([]byte(nil), rendered.document.raw...) + } + return &droidFileChange{ + filePath: rendered.document.filePath, label: label, + original: original, existed: rendered.document.exists, + next: append([]byte(nil), rendered.next...), mode: 0600, originalMode: originalMode, + remove: rendered.remove, + }, nil +} + +func writeDroidChanges(changes []*droidFileChange, label string, rename droidRenameFunc) error { + filtered := make([]droidFileChange, 0, len(changes)) + targets := map[string]bool{} + for _, change := range changes { + if change == nil || (change.existed && !change.remove && bytes.Equal(change.original, change.next)) { + continue + } + target := filepath.Clean(change.filePath) + if targets[target] { + return fmt.Errorf("%s contains duplicate file target %s", label, change.filePath) + } + targets[target] = true + filtered = append(filtered, *change) + } + if len(filtered) == 0 { + return nil + } + if rename == nil { + rename = os.Rename + } + staged := make([]droidStagedChange, 0, len(filtered)) + for _, change := range filtered { + item, err := stageDroidChange(change) + if err != nil { + cleanupErrors := cleanupDroidStaging(staged) + return joinDroidFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed") + } + staged = append(staged, item) + } + for index := range staged { + if err := commitDroidChange(&staged[index], rename); err != nil { + recoveryErrors := rollbackDroidChanges(staged, rename) + recoveryErrors = append(recoveryErrors, cleanupDroidStaging(staged)...) + return joinDroidFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") + } + } + cleanupErrors := cleanupDroidStaging(staged) + if len(cleanupErrors) > 0 { + return joinDroidFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed") + } + return nil +} + +func stageDroidChange(change droidFileChange) (droidStagedChange, error) { + if err := assertDroidUnchanged(change); err != nil { + return droidStagedChange{}, err + } + directory := filepath.Dir(change.filePath) + if err := os.MkdirAll(directory, 0700); err != nil { + return droidStagedChange{}, fmt.Errorf("create directory for %s at %s: %w", change.label, directory, err) + } + staged := droidStagedChange{change: change} + var err error + if change.remove { + staged.rollbackPath, err = reserveDroidPath(directory, filepath.Base(change.filePath), ".rollback") + } else { + staged.temporaryPath, err = writeDroidStagedFile( + directory, filepath.Base(change.filePath), ".tmp", change.next, change.mode, + ) + if err == nil && change.existed { + staged.rollbackPath, err = writeDroidStagedFile( + directory, filepath.Base(change.filePath), ".rollback", change.original, change.originalMode, + ) + } + } + if err != nil { + cleanupErrors := cleanupDroidStaging([]droidStagedChange{staged}) + return droidStagedChange{}, joinDroidFailure( + fmt.Errorf("stage %s: %w", change.label, err), cleanupErrors, "cleanup failed", + ) + } + return staged, nil +} + +func writeDroidStagedFile( + directory, basename, suffix string, + content []byte, + mode os.FileMode, +) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + failed := func(cause error) (string, error) { + return "", errors.Join(cause, file.Close(), removeDroidOptional(path)) + } + if err := file.Chmod(mode); err != nil { + return failed(err) + } + written, err := file.Write(content) + if err != nil { + return failed(err) + } + if written != len(content) { + return failed(io.ErrShortWrite) + } + if err := file.Sync(); err != nil { + return failed(err) + } + if err := file.Close(); err != nil { + return "", errors.Join(err, removeDroidOptional(path)) + } + return path, nil +} + +func reserveDroidPath(directory, basename, suffix string) (string, error) { + file, err := os.CreateTemp(directory, "."+basename+".*"+suffix) + if err != nil { + return "", err + } + path := file.Name() + if err := file.Close(); err != nil { + return "", errors.Join(err, removeDroidOptional(path)) + } + if err := os.Remove(path); err != nil { + return "", err + } + return path, nil +} + +func assertDroidUnchanged(change droidFileChange) error { + current, exists, err := readDroidOptional(change.filePath, change.label) + if err != nil { + return err + } + if exists != change.existed || !bytes.Equal(current, change.original) { + return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath) + } + return nil +} + +func commitDroidChange(staged *droidStagedChange, rename droidRenameFunc) error { + if err := assertDroidUnchanged(staged.change); err != nil { + return err + } + if staged.change.remove { + if staged.rollbackPath == "" { + return fmt.Errorf("missing rollback path for %s", staged.change.label) + } + if err := rename(staged.change.filePath, staged.rollbackPath); err != nil { + return fmt.Errorf("remove %s at %s: %w", staged.change.label, staged.change.filePath, err) + } + } else { + if staged.temporaryPath == "" { + return fmt.Errorf("missing staged file for %s", staged.change.label) + } + if err := rename(staged.temporaryPath, staged.change.filePath); err != nil { + return fmt.Errorf("commit %s at %s: %w", staged.change.label, staged.change.filePath, err) + } + } + staged.committed = true + return nil +} + +func rollbackDroidChanges(staged []droidStagedChange, rename droidRenameFunc) []error { + failures := make([]error, 0) + for index := len(staged) - 1; index >= 0; index-- { + item := &staged[index] + if !item.committed { + continue + } + var err error + switch { + case item.change.remove: + err = rename(item.rollbackPath, item.change.filePath) + case item.change.existed: + err = rename(item.rollbackPath, item.change.filePath) + default: + err = removeDroidOptional(item.change.filePath) + } + if err != nil { + failures = append(failures, fmt.Errorf("restore %s at %s: %w", item.change.label, item.change.filePath, err)) + } + } + return failures +} + +func cleanupDroidStaging(staged []droidStagedChange) []error { + failures := make([]error, 0) + for _, item := range staged { + for _, path := range []string{item.temporaryPath, item.rollbackPath} { + if err := removeDroidOptional(path); err != nil { + failures = append(failures, err) + } + } + } + return failures +} + +func removeDroidOptional(path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove staged Factory Droid file at %s: %w", path, err) + } + return nil +} + +func joinDroidFailure(cause error, related []error, label string) error { + if len(related) == 0 { + return cause + } + errorsWithContext := []error{cause, fmt.Errorf("%s: %w", label, errors.Join(related...))} + return errors.Join(errorsWithContext...) +} + +func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { + for _, contract := range contracts { + configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") + raw, exists, err := readDroidOptional(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists { + continue + } + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) + } + agentID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != droidAgentKey || !sameDroidAgentID(agentID, contract.agentID) { + continue + } + guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") + if err != nil { + return false, err + } + if guardExists && auditExists { + return true, nil + } + } + return false, nil +} diff --git a/sdks/go/cmd/elydora/plugins/droid_jsonc.go b/sdks/go/cmd/elydora/plugins/droid_jsonc.go new file mode 100644 index 0000000..3f04bf8 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_jsonc.go @@ -0,0 +1,259 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/tailscale/hujson" +) + +type droidJSONCEditor struct { + value hujson.Value + eol string + indent string +} + +func standardizeDroidJSONC(source []byte) ([]byte, error) { + value, err := parseDroidJSONC(source, "JSONC source") + if err != nil { + return nil, err + } + value.Standardize() + return value.Pack(), nil +} + +func parseDroidJSONC(source []byte, label string) (hujson.Value, error) { + value, err := hujson.Parse(source) + if err != nil { + return hujson.Value{}, fmt.Errorf("parse %s: %w", label, err) + } + if err := rejectDroidDuplicateKeys(&value, label, "$"); err != nil { + return hujson.Value{}, err + } + return value, nil +} + +func rejectDroidDuplicateKeys(value *hujson.Value, label, location string) error { + switch current := value.Value.(type) { + case *hujson.Object: + seen := make(map[string]bool, len(current.Members)) + for index := range current.Members { + member := ¤t.Members[index] + name := member.Name.Value.(hujson.Literal).String() + if seen[name] { + return fmt.Errorf("%s contains duplicate key %q at %s", label, name, location) + } + seen[name] = true + if err := rejectDroidDuplicateKeys(&member.Value, label, location+"/"+name); err != nil { + return err + } + } + case *hujson.Array: + for index := range current.Elements { + if err := rejectDroidDuplicateKeys( + ¤t.Elements[index], label, location+"/"+strconv.Itoa(index), + ); err != nil { + return err + } + } + } + return nil +} + +func decodeDroidJSONCObject(source []byte, label string) (map[string]any, error) { + value, err := parseDroidJSONC(source, label) + if err != nil { + return nil, err + } + if _, ok := value.Value.(*hujson.Object); !ok { + return nil, fmt.Errorf("%s must contain a JSON object", label) + } + standard := value.Clone() + standard.Standardize() + var object map[string]any + if err := json.Unmarshal(standard.Pack(), &object); err != nil { + return nil, fmt.Errorf("decode %s: %w", label, err) + } + return object, nil +} + +func newDroidJSONCEditor(source []byte, label string) (*droidJSONCEditor, error) { + value, err := parseDroidJSONC(source, label) + if err != nil { + return nil, err + } + return &droidJSONCEditor{ + value: value, eol: detectDroidEOL(source), indent: detectDroidIndent(source), + }, nil +} + +func detectDroidEOL(source []byte) string { + if bytes.Contains(source, []byte("\r\n")) { + return "\r\n" + } + return "\n" +} + +func detectDroidIndent(source []byte) string { + normalized := strings.ReplaceAll(string(source), "\r\n", "\n") + for _, line := range strings.Split(normalized, "\n") { + trimmed := strings.TrimLeft(line, "\t ") + if !strings.HasPrefix(trimmed, `"`) || trimmed == line { + continue + } + return line[:len(line)-len(trimmed)] + } + return " " +} + +func (editor *droidJSONCEditor) pack() []byte { + return editor.value.Pack() +} + +func (editor *droidJSONCEditor) remove(path []any) error { + operation := []map[string]any{{"op": "remove", "path": droidJSONPointer(path)}} + patch, err := json.Marshal(operation) + if err != nil { + return fmt.Errorf("encode Factory Droid JSONC removal: %w", err) + } + if err := editor.value.Patch(patch); err != nil { + return fmt.Errorf("apply Factory Droid JSONC removal at %s: %w", droidJSONPointer(path), err) + } + return nil +} + +func (editor *droidJSONCEditor) addProperty(objectPath []any, name string, value any) error { + container, err := findDroidJSONCValue(&editor.value, objectPath) + if err != nil { + return err + } + object, ok := container.Value.(*hujson.Object) + if !ok { + return fmt.Errorf("Factory Droid JSONC path %s must be an object", droidJSONPointer(objectPath)) + } + for _, member := range object.Members { + if member.Name.Value.(hujson.Literal).String() == name { + return fmt.Errorf("Factory Droid JSONC property %q already exists", name) + } + } + propertyDepth := len(objectPath) + 1 + formatted, err := editor.formattedValue(value, propertyDepth) + if err != nil { + return err + } + trailingComma := len(object.Members) > 0 && + object.Members[len(object.Members)-1].Value.AfterExtra != nil + leading := editor.newLeadingExtra(object.AfterExtra, propertyDepth) + object.AfterExtra = []byte(editor.eol + strings.Repeat(editor.indent, len(objectPath))) + member := hujson.ObjectMember{ + Name: hujson.Value{BeforeExtra: leading, Value: hujson.String(name)}, + Value: hujson.Value{BeforeExtra: []byte(" "), Value: formatted.Value}, + } + if trailingComma { + member.Value.AfterExtra = []byte{} + } + object.Members = append(object.Members, member) + return nil +} + +func (editor *droidJSONCEditor) appendArray(arrayPath []any, value any) error { + container, err := findDroidJSONCValue(&editor.value, arrayPath) + if err != nil { + return err + } + array, ok := container.Value.(*hujson.Array) + if !ok { + return fmt.Errorf("Factory Droid JSONC path %s must be an array", droidJSONPointer(arrayPath)) + } + arrayDepth := len(arrayPath) + formatted, err := editor.formattedValue(value, arrayDepth+1) + if err != nil { + return err + } + trailingComma := len(array.Elements) > 0 && array.Elements[len(array.Elements)-1].AfterExtra != nil + formatted.BeforeExtra = editor.newLeadingExtra(array.AfterExtra, arrayDepth+1) + if trailingComma { + formatted.AfterExtra = []byte{} + } else { + formatted.AfterExtra = nil + } + array.AfterExtra = []byte(editor.eol + strings.Repeat(editor.indent, arrayDepth)) + array.Elements = append(array.Elements, formatted) + return nil +} + +func (editor *droidJSONCEditor) formattedValue(value any, depth int) (hujson.Value, error) { + prefix := strings.Repeat(editor.indent, depth) + encoded, err := json.MarshalIndent(value, prefix, editor.indent) + if err != nil { + return hujson.Value{}, fmt.Errorf("encode Factory Droid hook value: %w", err) + } + if editor.eol != "\n" { + encoded = bytes.ReplaceAll(encoded, []byte("\n"), []byte(editor.eol)) + } + parsed, err := hujson.Parse(encoded) + if err != nil { + return hujson.Value{}, fmt.Errorf("parse generated Factory Droid hook value: %w", err) + } + return parsed, nil +} + +func (editor *droidJSONCEditor) newLeadingExtra(current hujson.Extra, depth int) hujson.Extra { + standard := []byte(editor.eol + strings.Repeat(editor.indent, depth)) + if bytes.Contains(current, []byte("//")) || bytes.Contains(current, []byte("/*")) { + combined := append(hujson.Extra(nil), current...) + return append(combined, standard...) + } + return standard +} + +func findDroidJSONCValue(root *hujson.Value, path []any) (*hujson.Value, error) { + current := root + for _, part := range path { + switch key := part.(type) { + case string: + object, ok := current.Value.(*hujson.Object) + if !ok { + return nil, fmt.Errorf("Factory Droid JSONC path %s must traverse an object", droidJSONPointer(path)) + } + found := false + for index := range object.Members { + if object.Members[index].Name.Value.(hujson.Literal).String() == key { + current = &object.Members[index].Value + found = true + break + } + } + if !found { + return nil, fmt.Errorf("Factory Droid JSONC path %s does not exist", droidJSONPointer(path)) + } + case int: + array, ok := current.Value.(*hujson.Array) + if !ok || key < 0 || key >= len(array.Elements) { + return nil, fmt.Errorf("Factory Droid JSONC array path %s is out of range", droidJSONPointer(path)) + } + current = &array.Elements[key] + default: + return nil, fmt.Errorf("unsupported Factory Droid JSONC path part %T", part) + } + } + return current, nil +} + +func droidJSONPointer(path []any) string { + var pointer strings.Builder + for _, part := range path { + pointer.WriteByte('/') + switch value := part.(type) { + case string: + value = strings.ReplaceAll(value, "~", "~0") + pointer.WriteString(strings.ReplaceAll(value, "/", "~1")) + case int: + pointer.WriteString(strconv.Itoa(value)) + } + } + return pointer.String() +} diff --git a/sdks/go/cmd/elydora/plugins/droid_recovery_test.go b/sdks/go/cmd/elydora/plugins/droid_recovery_test.go new file mode 100644 index 0000000..a4612db --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_recovery_test.go @@ -0,0 +1,263 @@ +package plugins + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDroidUninstallPreservesMixedGroupsAndLookalikes(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + root := readDroidTestObject(t, fixture.configPath) + group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath) + command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string) + group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{ + "type": "command", "command": "user-command", + }) + group["owner"] = "user" + root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]), + map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10, + }}, + }, + map[string]any{ + "matcher": "*", + "hooks": []any{map[string]any{ + "type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10, + }}, + }, + ) + writeDroidTestObject(t, fixture.configPath, root) + if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { + t.Fatalf("uninstall Factory Droid hooks: %v", err) + } + remainingRaw := readDroidTestFile(t, fixture.configPath) + for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} { + if !strings.Contains(remainingRaw, marker) { + t.Fatalf("uninstall removed lookalike %q", marker) + } + } + remaining := readDroidTestObject(t, fixture.configPath) + if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 { + t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"]) + } +} + +func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) { + tests := []struct { + name string + hooks *string + legacy *string + settings *string + pattern string + }{ + {"malformed hooks", droidString("{ malformed"), nil, nil, "parse Factory Droid hooks"}, + {"non-object hooks", droidJSON([]any{}), nil, nil, "JSON object"}, + {"duplicate event", droidString(`{ "PreToolUse": [], "PreToolUse": [] }`), nil, nil, "duplicate"}, + {"nested duplicate", droidString(`{ "PreToolUse": [{ "hooks": [], "hooks": [] }] }`), nil, nil, "duplicate"}, + {"null event", droidString(`{ "PreToolUse": null }`), nil, nil, "must be an array"}, + {"null group", droidString(`{ "PreToolUse": [null] }`), nil, nil, "must be an object"}, + {"invalid matcher", droidString(`{ "PreToolUse": [{ "matcher": "[", "hooks": [] }] }`), nil, nil, "regular expression"}, + {"invalid handler", droidString(`{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": 1 }] }] }`), nil, nil, "must be a string"}, + {"invalid flag", droidString(`{ "hooksDisabled": "yes" }`), nil, nil, "must be a boolean"}, + {"unsupported root field", droidString(`{ "theme": "dark" }`), nil, nil, "unsupported field"}, + {"malformed legacy", nil, droidString("{ malformed"), nil, "legacy hooks"}, + {"malformed settings", droidString(`{ "PreToolUse": [] }`), nil, droidString("{ malformed"), "settings"}, + {"null settings hooks", nil, nil, droidString(`{ "hooks": null }`), "JSON object"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: test.hooks, legacyHooks: test.legacy, settings: test.settings, + }) + originals := existingDroidSources(t, fixture) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("install error = %v, want substring %q", err, test.pattern) + } + for path, original := range originals { + if readDroidTestFile(t, path) != original { + t.Fatalf("failed install changed %s", path) + } + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingDroidFile(t, path) + } + }) + } +} + +func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) { + tests := []struct { + name string + mutate func(*droidFixture) + pattern string + }{ + { + name: "missing guard", + mutate: func(fixture *droidFixture) { + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard: %v", err) + } + }, + pattern: "guard runtime is missing", + }, + { + name: "invalid agent id", + mutate: func(fixture *droidFixture) { + fixture.config.AgentID = "../agent" + }, + pattern: "single non-empty path segment", + }, + { + name: "guard outside managed directory", + mutate: func(fixture *droidFixture) { + fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js") + }, + pattern: "managed agent directory", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + test.mutate(fixture) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("install error = %v, want substring %q", err, test.pattern) + } + for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingDroidFile(t, path) + } + }) + } +} + +func TestDroidTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + }) + originalRoot := readDroidTestFile(t, fixture.configPath) + originalSettings := readDroidTestFile(t, fixture.settingsPath) + failed := false + fixture.plugin.rename = func(source, destination string) error { + if !failed && sameDroidPath(destination, fixture.settingsPath) { + failed = true + return errors.New("simulated settings commit failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "Write Factory Droid installation") || !failed { + t.Fatalf("transaction error = %v, failed = %v", err, failed) + } + if readDroidTestFile(t, fixture.configPath) != originalRoot || + readDroidTestFile(t, fixture.settingsPath) != originalSettings { + t.Fatal("transaction rollback did not restore hook sources") + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingDroidFile(t, path) + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidTransactionDetectsExternalChangesAndPreservesThem(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + }) + external := "{\n \"external\": true,\n \"hooks\": { \"PostToolUse\": [] }\n}\n" + mutated := false + fixture.plugin.rename = func(source, destination string) error { + if !mutated { + mutated = true + if err := os.WriteFile(fixture.settingsPath, []byte(external), 0600); err != nil { + return err + } + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("external-change error = %v", err) + } + if readDroidTestFile(t, fixture.settingsPath) != external { + t.Fatal("transaction rollback overwrote an external settings change") + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + requireMissingDroidFile(t, path) + } + requireNoDroidStagingFiles(t, fixture.homeDir) +} + +func TestDroidRecoveryCommandsDoNotRequireNodeResolution(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + t.Setenv("PATH", "") + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("status without PATH = %#v, %v", status, err) + } + if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { + t.Fatalf("uninstall without PATH: %v", err) + } + requireMissingDroidFile(t, fixture.configPath) +} + +func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + 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 writeDroidTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("encode %s: %v", path, err) + } + if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string { + t.Helper() + result := map[string]string{} + for _, path := range []string{fixture.configPath, fixture.legacyPath, fixture.settingsPath} { + if raw, err := os.ReadFile(path); err == nil { + result[path] = string(raw) + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read source %s: %v", path, err) + } + } + return result +} + +func requireNoDroidStagingFiles(t *testing.T, root string) { + t.Helper() + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("staging file remains: %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk staging files: %v", err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/droid_test.go b/sdks/go/cmd/elydora/plugins/droid_test.go new file mode 100644 index 0000000..523c065 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_test.go @@ -0,0 +1,263 @@ +package plugins + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) { + entry := SupportedAgents[droidAgentKey] + if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" { + t.Fatalf("Factory Droid registry entry = %#v", entry) + } + if _, ok := NewPlugin(droidAgentKey).(*DroidPlugin); !ok { + t.Fatal("Factory Droid plugin is not registered") + } +} + +func TestDroidInstallPreservesJSONCAndUsesPerEventPrecedence(t *testing.T) { + hooks := `{ + // root hook source + "PreToolUse": [ + // keep root group comment + { "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] } + ], + "Notification": [] +} +` + settings := `{ + // general setting + "theme": "dark", + "hooks": { + // settings fallback event + "PostToolUse": [ + // keep settings group comment + { "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] } + ], + "showHookOutput": true, + }, +} +` + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidString(hooks), settings: droidString(settings), + }) + installDroidFixture(t, fixture) + firstRoot := readDroidTestFile(t, fixture.configPath) + firstSettings := readDroidTestFile(t, fixture.settingsPath) + installDroidFixture(t, fixture) + + if readDroidTestFile(t, fixture.configPath) != firstRoot || + readDroidTestFile(t, fixture.settingsPath) != firstSettings { + t.Fatal("idempotent install changed Factory Droid hook sources") + } + for _, marker := range []string{"root hook source", "keep root group comment"} { + if !strings.Contains(firstRoot, marker) { + t.Fatalf("root JSONC comment %q was lost", marker) + } + } + for _, marker := range []string{"general setting", "settings fallback event", "keep settings group comment"} { + if !strings.Contains(firstSettings, marker) { + t.Fatalf("settings JSONC comment %q was lost", marker) + } + } + root := readDroidTestObject(t, fixture.configPath) + userSettings := readDroidTestObject(t, fixture.settingsPath) + preGroups := requireDroidArray(t, root["PreToolUse"]) + if len(preGroups) != 2 || requireDroidObject(t, requireDroidArray(t, + requireDroidObject(t, preGroups[0])["hooks"])[0])["command"] != "root-user" { + t.Fatalf("root PreToolUse groups = %#v", preGroups) + } + if _, exists := root["PostToolUse"]; exists { + t.Fatalf("PostToolUse leaked into root source: %#v", root) + } + if userSettings["theme"] != "dark" { + t.Fatalf("settings theme = %#v", userSettings["theme"]) + } + settingsHooks := requireDroidObject(t, userSettings["hooks"]) + postGroups := requireDroidArray(t, settingsHooks["PostToolUse"]) + if len(postGroups) != 2 { + t.Fatalf("settings PostToolUse groups = %#v", postGroups) + } + if _, exists := settingsHooks["PreToolUse"]; exists { + t.Fatalf("PreToolUse leaked into settings source: %#v", settingsHooks) + } + for _, item := range []struct { + groups any + path string + }{{preGroups, fixture.guardPath}, {postGroups, fixture.hookPath}} { + handler := droidManagedHandler(t, item.groups, item.path) + if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) { + t.Fatalf("managed handler = %#v", handler) + } + } + requireMissingDroidFile(t, filepath.Join(fixture.workspaceDir, ".factory", "hooks.json")) +} + +func TestDroidInstallKeepsActiveLegacySource(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + legacyHooks: droidJSON(map[string]any{"PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + }) + installDroidFixture(t, fixture) + requireMissingDroidFile(t, fixture.configPath) + droidManagedHandler(t, readDroidTestObject(t, fixture.legacyPath)["PreToolUse"], fixture.guardPath) + settings := readDroidTestObject(t, fixture.settingsPath) + droidManagedHandler(t, requireDroidObject(t, settings["hooks"])["PostToolUse"], fixture.hookPath) +} + +func TestDroidInstallReusesSettingsContainerAndFormatting(t *testing.T) { + source := "{\r\n\t\"owner\": \"user\",\r\n\t\"hooks\": {}\r\n}\r\n" + fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(source)}) + installDroidFixture(t, fixture) + requireMissingDroidFile(t, fixture.configPath) + raw := readDroidTestFile(t, fixture.settingsPath) + if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") || + !strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") { + t.Fatalf("settings formatting was not preserved:\n%s", raw) + } + if readDroidTestObject(t, fixture.settingsPath)["owner"] != "user" { + t.Fatal("settings owner was lost") + } +} + +func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + }) + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create output pipe: %v", err) + } + original := os.Stdout + os.Stdout = writer + installErr := fixture.plugin.Install(fixture.config) + os.Stdout = original + closeErr := writer.Close() + output, readErr := io.ReadAll(reader) + _ = reader.Close() + if installErr != nil || closeErr != nil || readErr != nil { + t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr) + } + text := string(output) + for _, marker := range []string{fixture.configPath, fixture.settingsPath, "PreToolUse", "PostToolUse", "/hooks"} { + if !strings.Contains(text, marker) { + t.Fatalf("install output is missing %q: %s", marker, text) + } + } +} + +func TestDroidCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + encodedPath, 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(encodedPath) + ", Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("write capture audit runtime: %v", err) + } + root := readDroidTestObject(t, fixture.configPath) + guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath) + audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath) + prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}` + guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, prePayload) + if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard result = %#v", guardResult) + } + postPayload := "{\n \"session_id\": \"session-1\",\n \"hook_event_name\": \"PostToolUse\",\n \"tool_response\": {\"success\": true}\n}" + auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, postPayload) + if auditResult.exitCode != 0 { + t.Fatalf("audit result = %#v", auditResult) + } + if readDroidTestFile(t, capturePath) != postPayload { + t.Fatal("audit hook changed the official Factory Droid payload") + } +} + +func TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), + settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), + }) + installDroidFixture(t, fixture) + 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.hookPath); err != nil { + t.Fatalf("remove audit runtime: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("missing-runtime status = %#v, %v", status, err) + } + root := readDroidTestObject(t, fixture.configPath) + root["hooksDisabled"] = true + disabled, err := json.MarshalIndent(root, "", " ") + if err != nil { + t.Fatalf("encode disabled hooks: %v", err) + } + if err := os.WriteFile(fixture.configPath, append(disabled, '\n'), 0600); err != nil { + t.Fatalf("disable hooks: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("disabled status = %#v, %v", status, err) + } +} + +func TestDroidUninstallPreservesUserSourcesAndExactOwnership(t *testing.T) { + hooks := "{\n // keep root comment\n \"PreToolUse\": []\n}\n" + settings := "{\n \"theme\": \"dark\",\n \"hooks\": {\n" + + " // keep settings comment\n \"PostToolUse\": []\n }\n}\n" + fixture := prepareDroidFixture(t, droidFixtureOptions{ + hooks: droidString(hooks), settings: droidString(settings), + }) + installDroidFixture(t, fixture) + if err := fixture.plugin.Uninstall("agent-10"); err != nil { + t.Fatalf("uninstall lookalike agent: %v", err) + } + droidManagedHandler(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"], fixture.guardPath) + agentID := droidTestAgentID + if runtime.GOOS == "windows" { + agentID = "AGENT-1" + } + if err := fixture.plugin.Uninstall(agentID); err != nil { + t.Fatalf("uninstall Factory Droid hooks: %v", err) + } + rootRaw := readDroidTestFile(t, fixture.configPath) + settingsRaw := readDroidTestFile(t, fixture.settingsPath) + if !strings.Contains(rootRaw, "keep root comment") || !strings.Contains(settingsRaw, "keep settings comment") { + t.Fatal("uninstall removed user JSONC comments") + } + if len(requireDroidArray(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"])) != 0 { + t.Fatal("uninstall removed the user-owned empty event key") + } + currentSettings := readDroidTestObject(t, fixture.settingsPath) + if currentSettings["theme"] != "dark" || + len(requireDroidArray(t, requireDroidObject(t, currentSettings["hooks"])["PostToolUse"])) != 0 { + t.Fatalf("settings after uninstall = %#v", currentSettings) + } +} + +func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) { + fixture := prepareDroidFixture(t, droidFixtureOptions{}) + installDroidFixture(t, fixture) + if !strings.HasPrefix(readDroidTestFile(t, fixture.configPath), droidOwnedFileMarker) { + t.Fatal("created hook source is missing its ownership marker") + } + if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil { + t.Fatalf("uninstall Factory Droid hooks: %v", err) + } + requireMissingDroidFile(t, fixture.configPath) +} diff --git a/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go new file mode 100644 index 0000000..35c1e1b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/droid_test_helpers_test.go @@ -0,0 +1,237 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const droidTestAgentID = "agent-1" + +type droidFixtureOptions struct { + hooks *string + legacyHooks *string + settings *string + skipGuard bool +} + +type droidFixture struct { + plugin *DroidPlugin + config InstallConfig + homeDir string + workspaceDir string + factoryDir string + configPath string + legacyPath string + settingsPath string + agentDir string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +type droidCommandResult struct { + exitCode int + stderr string +} + +func droidString(value string) *string { + return &value +} + +func droidJSON(value any) *string { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + panic(err) + } + return droidString(string(encoded) + "\n") +} + +func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture { + t.Helper() + rootDir := t.TempDir() + homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + workspaceDir := filepath.Join(rootDir, "workspace") + factoryDir := filepath.Join(homeDir, ".factory") + configPath := filepath.Join(factoryDir, "hooks.json") + legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json") + settingsPath := filepath.Join(factoryDir, "settings.json") + agentDir := filepath.Join(homeDir, ".elydora", droidTestAgentID) + guardPath := filepath.Join(agentDir, droidGuardScript) + hookPath := filepath.Join(agentDir, droidAuditScript) + for _, directory := range []string{workspaceDir, agentDir} { + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + if !options.skipGuard { + guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" + if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + writeOptionalDroidFile(t, configPath, options.hooks) + writeOptionalDroidFile(t, legacyPath, options.legacyHooks) + writeOptionalDroidFile(t, settingsPath, options.settings) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + return &droidFixture{ + plugin: &DroidPlugin{}, + homeDir: homeDir, + workspaceDir: workspaceDir, + factoryDir: factoryDir, + configPath: configPath, + legacyPath: legacyPath, + settingsPath: settingsPath, + agentDir: agentDir, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + config: InstallConfig{ + AgentName: droidAgentKey, + OrgID: "org-1", + AgentID: droidTestAgentID, + PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + } +} + +func writeOptionalDroidFile(t *testing.T, path string, source *string) { + t.Helper() + if source == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(*source), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func installDroidFixture(t *testing.T, fixture *droidFixture) { + t.Helper() + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Factory Droid hooks: %v", err) + } +} + +func readDroidTestFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(raw) +} + +func requireMissingDroidFile(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be missing, got %v", path, err) + } +} + +func readDroidTestObject(t *testing.T, path string) map[string]any { + t.Helper() + standard, err := standardizeDroidJSONC([]byte(readDroidTestFile(t, path))) + if err != nil { + t.Fatalf("standardize %s: %v", path, err) + } + var object map[string]any + if err := json.Unmarshal(standard, &object); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return object +} + +func requireDroidObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value is not an object: %#v", value) + } + return object +} + +func requireDroidArray(t *testing.T, value any) []any { + t.Helper() + array, ok := value.([]any) + if !ok { + t.Fatalf("value is not an array: %#v", value) + } + return array +} + +func droidManagedHandler( + t *testing.T, + groupsValue any, + scriptPath string, +) map[string]any { + t.Helper() + group := droidManagedGroup(t, groupsValue, scriptPath) + for _, handlerValue := range requireDroidArray(t, group["hooks"]) { + handler := requireDroidObject(t, handlerValue) + command, _ := handler["command"].(string) + if strings.Contains(command, scriptPath) { + return handler + } + } + t.Fatalf("managed handler for %s not found", scriptPath) + return nil +} + +func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[string]any { + t.Helper() + for _, groupValue := range requireDroidArray(t, groupsValue) { + group := requireDroidObject(t, groupValue) + for _, handlerValue := range requireDroidArray(t, group["hooks"]) { + handler := requireDroidObject(t, handlerValue) + command, _ := handler["command"].(string) + if strings.Contains(command, scriptPath) { + return group + } + } + } + t.Fatalf("managed group for %s not found", scriptPath) + return nil +} + +func runDroidCommand(t *testing.T, command, homeDir, payload string) droidCommandResult { + t.Helper() + var process *exec.Cmd + if runtime.GOOS == "windows" { + commandFile := filepath.Join(t.TempDir(), "run-droid-hook.cmd") + if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { + t.Fatalf("write command file: %v", err) + } + process = exec.Command("cmd.exe", "/d", "/c", commandFile) + } else { + process = exec.Command("sh", "-c", command) + } + process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + process.Stdin = bytes.NewBufferString(payload) + var stderr bytes.Buffer + process.Stderr = &stderr + err := process.Run() + if err == nil { + return droidCommandResult{stderr: stderr.String()} + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return droidCommandResult{exitCode: exitError.ExitCode(), stderr: stderr.String()} + } + t.Fatalf("run Factory Droid hook command: %v", err) + return droidCommandResult{} +} diff --git a/sdks/go/cmd/elydora/plugins/registry.go b/sdks/go/cmd/elydora/plugins/registry.go index 228ea23..388bb8e 100644 --- a/sdks/go/cmd/elydora/plugins/registry.go +++ b/sdks/go/cmd/elydora/plugins/registry.go @@ -14,6 +14,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"}, "cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, + "droid": {Name: "Factory Droid", ConfigDir: "~/.factory", ConfigFile: "hooks.json"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, "grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"}, "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"}, @@ -38,6 +39,8 @@ func NewPlugin(agentName string) AgentPlugin { return &ClinePlugin{} case "cursor": return &CursorPlugin{} + case "droid": + return &DroidPlugin{} case "gemini": return &GeminiPlugin{} case "grok": diff --git a/sdks/go/go.mod b/sdks/go/go.mod index 77d064f..15840ca 100644 --- a/sdks/go/go.mod +++ b/sdks/go/go.mod @@ -3,3 +3,5 @@ module github.com/Elydora-Infrastructure/Elydora-Go-SDK go 1.21.0 require github.com/pelletier/go-toml/v2 v2.4.3 + +require github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a diff --git a/sdks/go/go.sum b/sdks/go/go.sum index ddaf7d6..f3931d0 100644 --- a/sdks/go/go.sum +++ b/sdks/go/go.sum @@ -1,2 +1,6 @@ +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=