feat: complete Factory Droid Go adapter delivery

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 11:52:08 -04:00
1 parent b92c9bea68
commit 937bda3589
14 files changed
+2293 -4

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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": [
{
+4 -1
View File
@@ -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` |
+215
View File
@@ -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
}
+300
View File
@@ -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)
}
@@ -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
}
+329
View File
@@ -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
}
+259
View File
@@ -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 := &current.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(
&current.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()
}
@@ -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)
}
}
+263
View File
@@ -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)
}
@@ -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{}
}
+3
View File
@@ -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":
+2
View File
@@ -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
+4
View File
@@ -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=