feat: complete Factory Droid Go adapter delivery
This commit is contained in:
1 parent
b92c9bea68
commit
937bda3589
14 files changed
+2293
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
Reference in new issue
Block a user