fix(droid): align hooks with Factory 0.175

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 14:34:54 -04:00
1 parent 827374e7f8
commit 4521cbe32f
15 files changed
+2749 -881

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
const droidCLIPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
workspace := filepath.Join(root, "workspace with spaces")
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
t.Fatalf("create Factory Droid CLI workspace: %v", err)
}
return homeDir, workspace, []string{
"HOME=" + homeDir,
"USERPROFILE=" + homeDir,
}
}
func TestDroidCLIPreflightBlocksPolicyBeforeRuntimeCreation(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, workspace, environment := prepareDroidCLIEnvironment(t)
settingsPath := filepath.Join(homeDir, ".factory", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil {
t.Fatalf("create Factory settings directory: %v", err)
}
original := "{\"hooksDisabled\":true}\n"
if err := os.WriteFile(settingsPath, []byte(original), 0600); err != nil {
t.Fatalf("write Factory settings: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
writeClineCLISecret(t, keyPath, droidCLIPrivateKey)
_, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "droid",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--kid", "kid-1",
"--base-url", "https://api.elydora.test",
)
if exit == 0 || !strings.Contains(stderr, "hooksDisabled") {
t.Fatalf("Factory Droid CLI preflight = %d, %q", exit, stderr)
}
raw, err := os.ReadFile(settingsPath)
if err != nil || string(raw) != original {
t.Fatalf("Factory settings changed: %q, %v", raw, err)
}
for _, path := range []string{
filepath.Join(homeDir, ".elydora"),
filepath.Join(homeDir, ".factory", "hooks.json"),
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("path created during failed preflight at %s: %v", path, err)
}
}
}
func TestDroidCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, workspace, environment := prepareDroidCLIEnvironment(t)
keyPath := filepath.Join(t.TempDir(), "private.key")
tokenPath := filepath.Join(t.TempDir(), "token")
writeClineCLISecret(t, keyPath, droidCLIPrivateKey)
writeClineCLISecret(t, tokenPath, "token-1")
stdout, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "droid",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--token-file", tokenPath,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Factory Droid") {
t.Fatalf("Factory Droid CLI install = %d, %q, %q", exit, stdout, stderr)
}
agentDir := filepath.Join(homeDir, ".elydora", "agent-1")
hooksPath := filepath.Join(homeDir, ".factory", "hooks.json")
for _, path := range []string{
filepath.Join(agentDir, "guard.js"),
filepath.Join(agentDir, "config.json"),
filepath.Join(agentDir, "private.key"),
filepath.Join(agentDir, "hook.js"),
hooksPath,
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("installed file missing at %s: %v", path, err)
}
}
hooks, err := os.ReadFile(hooksPath)
if err != nil || !strings.Contains(string(hooks), `"hooks"`) ||
!strings.Contains(string(hooks), `"PreToolUse"`) ||
!strings.Contains(string(hooks), `"PostToolUse"`) {
t.Fatalf("Factory Droid hooks = %q, %v", hooks, err)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"status",
"--agent", "droid",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("Factory Droid CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"uninstall",
"--agent", "droid",
"--agent-id", "agent-1",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("Factory Droid CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
for _, path := range []string{agentDir, hooksPath} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed path remains at %s: %v", path, err)
}
}
}
+86 -151
View File
@@ -1,145 +1,143 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
import "fmt"
// DroidPlugin manages Factory Droid native user hooks.
type DroidPlugin struct {
rename renameFunc
}
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"`
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime,
// credentials, and hook documents in one transaction.
func (p *DroidPlugin) ManagesGuardRuntime() bool {
return true
}
func (p *DroidPlugin) Install(config InstallConfig) error {
runtimeRoot, agentDirectory, err := droidAgentDirectory(config.AgentID)
if err != nil {
return err
}
// PreflightInstall validates every hook source, policy layer, credential,
// matcher, and runtime identity before any write.
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
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
}
_, _, err = preflightDroidInstallation(config, sources)
return err
}
func renderDroidInstallation(
sources *droidSources,
guardPath, auditPath, nodePath, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
target := activeDroidDocument(sources)
groups := map[string]map[string]any{
"PreToolUse": buildDroidGroup(nodePath, config.GuardScriptPath),
"PreToolUse": buildDroidGroup(nodePath, guardPath),
"PostToolUse": buildDroidGroup(nodePath, auditPath),
}
documents := uniqueDroidDocuments(
sources.primary,
droidSettingsDocument(sources.settings),
selection.targets["PreToolUse"],
selection.targets["PostToolUse"],
)
documents := droidInstallationDocuments(sources)
rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents {
result, renderErr := renderDroidDocument(
document, "", runtimeRoot, droidAdditionsFor(document, selection.targets, groups),
item, err := renderDroidDocument(
document,
"",
runtimeRoot,
droidAdditionsFor(document, target, groups),
)
if renderErr != nil {
return renderErr
if err != nil {
return nil, err
}
rendered = append(rendered, result)
rendered = append(rendered, item)
}
changes, err := prepareDroidInstallationChanges(config, agentDirectory, auditPath, rendered)
return rendered, nil
}
func (p *DroidPlugin) Install(config InstallConfig) error {
sources, err := readDroidSources()
if err != nil {
return err
}
if err := writeChanges(changes, "Write Factory Droid installation", p.rename); err != nil {
paths, nodePath, err := preflightDroidInstallation(config, sources)
if err != nil {
return err
}
fmt.Printf(
" Factory Droid: PreToolUse: %s, PostToolUse: %s\n",
selection.targets["PreToolUse"].filePath,
selection.targets["PostToolUse"].filePath,
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
fmt.Println(" Factory Droid: run /hooks to review the Elydora hook changes.")
if err != nil {
return err
}
prepared, err := prepareDroidInstallation(config, sources, rendered)
if err != nil {
return err
}
if err := commitDroidInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Factory Droid hooks: %s\n", activeDroidDocument(sources).filePath)
fmt.Println("Factory Droid: run /hooks to review the Elydora hook changes.")
return nil
}
func renderDroidUninstall(
sources *droidSources,
agentID, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
documents := droidSourceDocuments(sources)
rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents {
item, err := renderDroidDocument(
document,
agentID,
runtimeRoot,
map[string]map[string]any{},
)
if err != nil {
return nil, err
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *DroidPlugin) Uninstall(agentID string) error {
sources, err := readDroidSources()
if err != nil {
return err
}
runtimeRoot, err := droidRuntimeRoot()
if err != nil {
return err
}
sources, err := readDroidSources()
rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot)
if err != nil {
return err
}
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings))
changes := make([]*fileChange, 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)
prepared, err := prepareDroidUninstall(rendered)
if err != nil {
return err
}
return writeChanges(changes, "Write Factory Droid hook sources", p.rename)
return commitDroidUninstall(prepared, p.rename)
}
func (p *DroidPlugin) Status() (PluginStatus, error) {
entry := SupportedAgents[droidAgentKey]
status := PluginStatus{
AgentName: droidAgentKey, DisplayName: entry.Name,
}
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 {
if droidHookBlocked(sources) != nil {
return status, nil
}
runtimeRoot, err := droidRuntimeRoot()
if err != nil {
return status, err
}
contracts := droidRuntimeContracts(effective, runtimeRoot)
contracts := droidRuntimeContracts(effectiveDroidHooks(sources), runtimeRoot)
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
@@ -151,66 +149,3 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
status.Installed = status.HookScriptExists
return status, nil
}
func droidAgentDirectory(agentID string) (string, string, error) {
runtimeRoot, err := droidRuntimeRoot()
if err != nil {
return "", "", err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(agentID)
if err != nil {
return "", "", err
}
return runtimeRoot, agentDirectory, nil
}
func droidSettingsDocument(settings *droidDocument) *droidDocument {
if settings != nil && settings.hasHooksContainer {
return settings
}
return nil
}
func prepareDroidInstallationChanges(
config InstallConfig,
agentDirectory, auditPath string,
rendered []*droidRenderedDocument,
) ([]*fileChange, 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), 0600},
{auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700},
}
changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered))
for _, item := range runtimeChanges {
change, changeErr := prepareFileChange(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
}
+254 -138
View File
@@ -3,8 +3,8 @@ package plugins
import (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
)
@@ -13,21 +13,22 @@ type droidDocument struct {
filePath string
exists bool
raw []byte
snapshot *managedFileSnapshot
root map[string]any
hooks droidHookSettings
basePath []any
hasHooksContainer bool
hooksDisabled *bool
showHookOutput *bool
ownedFile bool
}
type droidSources struct {
rootPath string
primary *droidDocument
settings *droidDocument
}
type droidInstallationTargets struct {
targets map[string]*droidDocument
createdRoot *droidDocument
root *droidDocument
legacy *droidDocument
settings *droidDocument
localSettings *droidDocument
policy *droidPolicyState
}
type droidRenderedDocument struct {
@@ -37,72 +38,39 @@ type droidRenderedDocument struct {
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
type droidHookBlock struct {
field string
filePath string
label string
}
func readDroidSources() (*droidSources, error) {
rootPath, legacyPath, settingsPath, err := droidFactoryPaths()
if err != nil {
return nil, err
}
rootRaw, rootExists, err := readOptionalFile(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 := readOptionalFile(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 := readOptionalFile(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) {
func parseDroidDocument(
exists bool,
filePath, kind string,
raw []byte,
snapshot *managedFileSnapshot,
) (*droidDocument, error) {
label := droidDocumentLabel(kind, filePath)
root, err := decodeJSONCObject(raw, label, true)
if err != nil {
return nil, err
}
document := &droidDocument{
kind: kind, filePath: filePath, exists: exists, raw: append([]byte(nil), raw...),
kind: kind, filePath: filePath, exists: exists,
raw: append([]byte(nil), raw...), snapshot: snapshot, root: root,
ownedFile: bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)),
}
if kind == "settings" {
hooksValue, hasHooks := root["hooks"]
if kind == "settings" || kind == "local-settings" {
document.basePath = []any{"hooks"}
document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label)
if err != nil {
return nil, err
}
document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label)
if err != nil {
return nil, err
}
hooksValue, hasHooks := root["hooks"]
document.hasHooksContainer = hasHooks
if !hasHooks {
document.hooks = droidHookSettings{}
@@ -111,16 +79,51 @@ func parseDroidDocument(exists bool, filePath, kind string, raw []byte) (*droidD
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))
if hooksValue, hasHooks := root["hooks"]; hasHooks {
document.basePath = []any{"hooks"}
document.hasHooksContainer = true
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err
}
document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label)
if err != nil {
return nil, err
}
document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label)
if err != nil {
return nil, err
}
direct := make(map[string]any, len(root))
for key, value := range root {
if key != "hooksDisabled" && key != "showHookOutput" {
direct[key] = value
}
}
document.hooks, err = readDroidHookSettings(direct, label)
return document, err
}
func droidOptionalBoolean(
root map[string]any,
field, label string,
) (*bool, error) {
value, exists := root[field]
if !exists {
return nil, nil
}
boolean, ok := value.(bool)
if !ok {
return nil, fmt.Errorf(`%s field %q must be a boolean`, label, field)
}
return &boolean, nil
}
func droidDocumentLabel(kind, filePath string) string {
switch kind {
case "settings":
return fmt.Sprintf("Factory Droid settings at %s", filePath)
case "local-settings":
return fmt.Sprintf("Factory Droid local settings at %s", filePath)
case "legacy":
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
default:
@@ -128,41 +131,91 @@ func droidDocumentLabel(kind, filePath string) string {
}
}
func createDroidDocument(filePath, kind string, raw []byte) (*droidDocument, error) {
return parseDroidDocument(false, filePath, kind, raw, nil)
}
func createOwnedDroidDocument(filePath string) (*droidDocument, error) {
return parseDroidDocument(
false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"),
return createDroidDocument(
filePath,
"hooks",
[]byte(droidOwnedFileMarker+"\n{\n \"hooks\": {}\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
}
func activeDroidDocument(sources *droidSources) *droidDocument {
switch {
case sources.root.exists:
return sources.root
case sources.legacy.exists:
return sources.legacy
case sources.localSettings.hasHooksContainer:
return sources.localSettings
case sources.settings.hasHooksContainer:
return sources.settings
default:
return sources.root
}
return selection, nil
}
func hasDroidHookField(settings droidHookSettings, key string) bool {
_, exists := settings[key]
return exists
func effectiveDroidHooks(sources *droidSources) droidHookSettings {
return activeDroidDocument(sources).hooks
}
func droidHookBlocked(sources *droidSources) *droidHookBlock {
if sources.policy != nil && sources.policy.allowManagedHooksOnlyBy != nil {
origin := sources.policy.allowManagedHooksOnlyBy
return &droidHookBlock{"allowManagedHooksOnly", origin.filePath, origin.label}
}
if sources.policy != nil && sources.policy.hooksDisabled != nil {
if *sources.policy.hooksDisabled {
origin := sources.policy.hooksDisabledBy
return &droidHookBlock{"hooksDisabled", origin.filePath, origin.label}
}
return nil
}
selected := sources.settings
if sources.localSettings.hooksDisabled != nil {
selected = sources.localSettings
}
if selected.hooksDisabled != nil && *selected.hooksDisabled {
return &droidHookBlock{
"hooksDisabled", selected.filePath,
droidDocumentLabel(selected.kind, selected.filePath),
}
}
active := activeDroidDocument(sources)
if active.hooksDisabled != nil && *active.hooksDisabled {
return &droidHookBlock{
"hooksDisabled", active.filePath,
droidDocumentLabel(active.kind, active.filePath),
}
}
return nil
}
func droidSourceDocuments(sources *droidSources) []*droidDocument {
return []*droidDocument{
sources.root, sources.legacy, sources.settings, sources.localSettings,
}
}
func droidInstallationDocuments(sources *droidSources) []*droidDocument {
target := activeDroidDocument(sources)
candidates := []*droidDocument{target}
if sources.root.exists {
candidates = append(candidates, sources.root)
}
if sources.legacy.exists {
candidates = append(candidates, sources.legacy)
}
if sources.settings.hasHooksContainer {
candidates = append(candidates, sources.settings)
}
if sources.localSettings.hasHooksContainer {
candidates = append(candidates, sources.localSettings)
}
return uniqueDroidDocuments(candidates...)
}
func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
@@ -183,17 +236,13 @@ func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
}
func droidAdditionsFor(
document *droidDocument,
targets map[string]*droidDocument,
document, target *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]
}
if sameDroidPath(document.filePath, target.filePath) {
return groups
}
return additions
return map[string]map[string]any{}
}
func renderDroidDocument(
@@ -201,7 +250,14 @@ func renderDroidDocument(
agentID, runtimeRoot string,
additions map[string]map[string]any,
) (*droidRenderedDocument, error) {
editor, err := newJSONCEditor(document.raw, droidDocumentLabel(document.kind, document.filePath), true)
if len(additions) > 0 && droidHasExactInstallation(document, additions, runtimeRoot) {
return &droidRenderedDocument{document: document, next: document.raw}, nil
}
editor, err := newJSONCEditor(
document.raw,
droidDocumentLabel(document.kind, document.filePath),
true,
)
if err != nil {
return nil, err
}
@@ -212,28 +268,33 @@ func renderDroidDocument(
}
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
for _, event := range droidToolEvents {
removedEvent := false
for _, removal := range removals {
if removal.event != event {
continue
}
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
removedEvent = true
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 removedEvent {
current, parseErr := droidHooksFromRaw(document, editor.pack())
if parseErr != nil {
return nil, parseErr
}
groups, exists := current[event].([]any)
if exists && len(groups) == 0 {
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
return nil, err
@@ -246,12 +307,12 @@ func renderDroidDocument(
if !exists {
continue
}
current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
if err != nil {
return nil, err
current, parseErr := droidHooksFromRaw(document, editor.pack())
if parseErr != nil {
return nil, parseErr
}
eventPath := appendDroidPath(document.basePath, event)
if hasDroidHookField(current.hooks, event) {
if _, exists := current[event]; exists {
err = editor.appendArray(eventPath, group)
} else {
err = editor.addProperty(document.basePath, event, []any{group})
@@ -261,16 +322,77 @@ func renderDroidDocument(
}
}
next := editor.pack()
nextDocument, err := parseDroidDocument(document.exists, document.filePath, document.kind, next)
nextDocument, err := parseDroidDocument(
document.exists, document.filePath, document.kind, next, document.snapshot,
)
if err != nil {
return nil, err
}
remove := len(additions) == 0 && document.ownedFile && document.kind != "settings" && len(nextDocument.hooks) == 0
remove := len(additions) == 0 && document.exists && document.ownedFile &&
droidHookDocumentEmpty(nextDocument)
return &droidRenderedDocument{
document: document, changed: remove || !bytes.Equal(next, document.raw), next: next, remove: remove,
document: document,
changed: remove || !bytes.Equal(next, document.raw),
next: next,
remove: remove,
}, nil
}
func droidHasExactInstallation(
document *droidDocument,
additions map[string]map[string]any,
runtimeRoot string,
) bool {
if len(additions) != len(droidToolEvents) {
return false
}
removals := managedDroidRemovals(document.hooks, "", runtimeRoot)
if len(removals) != len(droidToolEvents) {
return false
}
for _, event := range droidToolEvents {
matches := make([]droidManagedRemoval, 0, 1)
for _, removal := range removals {
if removal.event == event {
matches = append(matches, removal)
}
}
if len(matches) != 1 || !matches[0].removeGroup {
return false
}
groups := document.hooks[event].([]any)
if !reflect.DeepEqual(groups[matches[0].groupIndex], additions[event]) {
return false
}
}
return true
}
func droidHooksFromRaw(
document *droidDocument,
raw []byte,
) (droidHookSettings, error) {
current, err := parseDroidDocument(
document.exists, document.filePath, document.kind, raw, document.snapshot,
)
if err != nil {
return nil, err
}
return current.hooks, nil
}
func droidHookDocumentEmpty(document *droidDocument) bool {
if document.kind == "settings" || document.kind == "local-settings" || len(document.hooks) > 0 {
return false
}
for field := range document.root {
if field != "hooks" {
return false
}
}
return true
}
func appendDroidPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts))
path = append(path, base...)
@@ -278,11 +400,5 @@ func appendDroidPath(base []any, parts ...any) []any {
}
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
return activeDroidDocument(sources).filePath
}
+123 -102
View File
@@ -6,33 +6,26 @@ import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const (
droidAgentKey = "droid"
droidGuardScript = "guard.js"
droidAuditScript = "hook.js"
droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora"
droidPOSIXApostrophe = `'"'"'`
droidAgentKey = "droid"
droidGuardScript = "guard.js"
droidAuditScript = "hook.js"
droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora"
droidPOSIXApostrophe = `'"'"'`
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
)
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 {
@@ -58,30 +51,20 @@ func readDroidHookSettings(value any, label string) (droidHookSettings, error) {
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)
}
hooks := make(droidHookSettings, len(object))
for event, item := range object {
groups, ok := item.([]any)
if !ok {
return nil, fmt.Errorf(`%s field %q must be an array`, label, key)
return nil, fmt.Errorf(`%s field %q must be an array`, label, event)
}
for groupIndex, group := range groups {
if err := validateDroidGroup(group, label, key, groupIndex); err != nil {
if err := validateDroidGroup(group, label, event, groupIndex); err != nil {
return nil, err
}
}
settings[key] = item
hooks[event] = item
}
return settings, nil
return hooks, nil
}
func validateDroidGroup(value any, label, event string, groupIndex int) error {
@@ -121,13 +104,14 @@ func validateDroidHandler(value any, groupLabel string, handlerIndex int) error
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)
command, ok := handler["command"].(string)
if !ok || strings.TrimSpace(command) == "" {
return fmt.Errorf("%s command must be a non-empty 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)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf("%s timeout must be a positive finite number", location)
}
}
return nil
@@ -137,19 +121,19 @@ 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) {
groups, _ := value.([]any)
for index, groupValue := range groups {
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,
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,
Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index),
Pattern: expression,
})
}
}
@@ -196,17 +180,14 @@ for (const entry of entries) {
func buildDroidCommand(nodePath, scriptPath string) string {
if runtime.GOOS == "windows" {
return quoteDroidWindowsArgument(nodePath) + " " + quoteDroidWindowsArgument(scriptPath)
return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
}
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
}
func quoteDroidWindowsArgument(value string) string {
quoted := quoteWindowsArgument(value)
if strings.HasPrefix(quoted, `"`) {
return quoted
}
return `"` + quoted + `"`
func quoteDroidPowerShellArgument(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
@@ -218,38 +199,80 @@ func buildDroidGroup(nodePath, scriptPath string) map[string]any {
}
}
func parseDroidCommand(command string) (string, string, bool) {
reader := readDroidPOSIXArgument
if runtime.GOOS == "windows" {
reader = readDroidWindowsArgument
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
if runtime.GOOS != "windows" {
return parseDroidPOSIXCommand(command)
}
executable, next, ok := reader(command, 0)
executable, script, ok := parseDroidPowerShellCommand(command)
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
return executable, script, ok
}
return parseDroidLegacyWindowsCommand(command)
}
func parseDroidPOSIXCommand(command string) (string, string, bool) {
executable, next, ok := readDroidPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := reader(command, next+1)
script, end, ok := readDroidPOSIXArgument(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] != '"' {
func parseDroidPowerShellCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readDroidPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != ""
}
func parseDroidLegacyWindowsCommand(command string) (string, string, bool) {
executable, next, ok := readDroidLegacyWindowsArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidLegacyWindowsArgument(command, next+1)
return executable, script, ok && end == len(command) && executable != "" && script != ""
}
func readDroidPowerShellArgument(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('"')
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if 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 value.String(), index + 1, true
}
return "", start, false
}
func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '"' {
return "", start, false
}
end := strings.IndexByte(command[start+1:], '"')
if end < 0 {
return "", start, false
}
end += start + 1
value := command[start+1 : end]
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
}
func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
@@ -293,14 +316,14 @@ func sameDroidAgentID(left, right string) bool {
}
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
return AgentRuntimeRoot()
}
func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) {
func managedDroidAgentID(
handler map[string]any,
scriptName, runtimeRoot string,
includeLegacy ...bool,
) (string, bool) {
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout {
return "", false
}
@@ -308,9 +331,9 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot 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) {
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) {
return "", false
}
agentDirectory := filepath.Dir(scriptPath)
@@ -323,10 +346,7 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string)
func isDroidNodeExecutable(path string) bool {
name := filepath.Base(path)
if runtime.GOOS == "windows" {
return strings.EqualFold(name, "node.exe")
}
return name == "node"
return name == "node" || strings.EqualFold(name, "node.exe")
}
func sameDroidFileName(left, right string) bool {
@@ -347,7 +367,9 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
handlers := group["hooks"].([]any)
indexes := make([]int, 0)
for handlerIndex, handlerValue := range handlers {
managedID, managed := managedDroidAgentID(handlerValue.(map[string]any), contract.script, runtimeRoot)
managedID, managed := managedDroidAgentID(
handlerValue.(map[string]any), contract.script, runtimeRoot, true,
)
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) {
indexes = append(indexes, handlerIndex)
}
@@ -357,8 +379,11 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
}
_, 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})
exactGroup := len(group) == 2 && hasMatcher && hasHooks &&
group["matcher"] == "*" && len(indexes) == len(handlers)
removals = append(removals, droidManagedRemoval{
contract.event, groupIndex, indexes, exactGroup,
})
}
}
return removals
@@ -367,20 +392,24 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
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
guardIDs := make([]string, 0, len(guards))
for agentID := range guards {
guardIDs = append(guardIDs, agentID)
}
sort.Strings(guardIDs)
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
for _, guardID := range guardIDs {
for auditID := range audits {
if sameDroidAgentID(guardID, auditID) {
matched = true
break
if !sameDroidAgentID(guardID, auditID) {
continue
}
}
if matched {
root := filepath.Join(runtimeRoot, guardID)
contracts = append(contracts, droidRuntimeContract{
guardID, filepath.Join(root, droidGuardScript), filepath.Join(root, droidAuditScript),
guardID,
filepath.Join(root, droidGuardScript),
filepath.Join(root, droidAuditScript),
})
break
}
}
return contracts
@@ -390,22 +419,14 @@ func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot stri
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
}
group := groupValue.(map[string]any)
handlers := group["hooks"].([]any)
if len(group) != 2 || group["matcher"] != "*" || len(handlers) != 1 {
continue
}
if agentID, ok := managedDroidAgentID(handlers[0].(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
}
+400
View File
@@ -0,0 +1,400 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type droidRuntimePaths struct {
runtimeRoot string
agentDirectory string
guardPath string
auditPath string
}
type preparedDroidInstallation struct {
changes []*fileChange
preconditions []filePrecondition
paths *droidRuntimePaths
}
type preparedDroidUninstall struct {
changes []*fileChange
preconditions []filePrecondition
}
func droidSourceLabel(document *droidDocument) string {
switch document.kind {
case "settings":
return "Factory Droid user settings"
case "local-settings":
return "Factory Droid local settings"
case "legacy":
return "Factory Droid legacy hooks"
default:
return "Factory Droid user hooks"
}
}
func validateDroidInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != droidAgentKey {
return fmt.Errorf(
"Factory Droid installation requires agent name %s",
droidAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) {
if err := validateDroidInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &droidRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
guardPath: filepath.Join(agentDirectory, droidGuardScript),
auditPath: filepath.Join(agentDirectory, droidAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameDroidPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameDroidPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightDroidInstallation(
config InstallConfig,
sources *droidSources,
) (*droidRuntimePaths, string, error) {
if sources == nil || sources.root == nil || sources.policy == nil {
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
}
if err := requireDroidHooksEnabled(sources); err != nil {
return nil, "", err
}
hooks := make([]droidHookSettings, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
hooks = append(hooks, document.hooks)
}
if err := validateDroidRegexes(hooks...); err != nil {
return nil, "", err
}
paths, err := droidAgentPaths(config)
if err != nil {
return nil, "", err
}
if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Factory Droid hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil
}
func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID,
"agent_id": config.AgentID,
"kid": config.KID,
"base_url": config.BaseURL,
"agent_name": droidAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareRenderedDroidChange(
rendered *droidRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSnapshotSourceChange(
rendered.document.filePath,
droidSourceLabel(rendered.document),
rendered.document.snapshot,
rendered.next,
0600,
rendered.remove,
)
}
func validateDroidRenderedSet(
sources *droidSources,
rendered []*droidRenderedDocument,
) error {
expected := droidInstallationDocuments(sources)
if len(rendered) != len(expected) {
return fmt.Errorf("Factory Droid rendered source set is incomplete")
}
for _, document := range expected {
matches := 0
for _, item := range rendered {
if item != nil && item.document != nil &&
sameDroidPath(item.document.filePath, document.filePath) {
matches++
}
}
if matches != 1 {
return fmt.Errorf("Factory Droid rendered source set contains unexpected paths")
}
}
return nil
}
func droidSourcePreconditions(
sources *droidSources,
changedPaths []string,
) []filePrecondition {
preconditions := make([]filePrecondition, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
changed := false
for _, path := range changedPaths {
if sameDroidPath(document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: document.filePath,
label: droidSourceLabel(document),
snapshot: document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return preconditions
}
func prepareDroidInstallation(
config InstallConfig,
sources *droidSources,
rendered []*droidRenderedDocument,
) (*preparedDroidInstallation, error) {
paths, _, err := preflightDroidInstallation(config, sources)
if err != nil {
return nil, err
}
if err := validateDroidRenderedSet(sources, rendered); err != nil {
return nil, err
}
runtimeConfig, err := buildDroidRuntimeConfig(config)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath,
"Elydora guard runtime",
[]byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")),
0700,
},
{filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
changedPaths := make([]string, 0, len(rendered))
for _, document := range rendered {
change, changeErr := prepareRenderedDroidChange(document)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := droidSourcePreconditions(sources, changedPaths)
preconditions = append(preconditions, sources.policy.preconditions...)
return &preparedDroidInstallation{
changes: changes,
preconditions: preconditions,
paths: paths,
}, nil
}
func writeDroidChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory string,
preconditions []filePrecondition,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...)
}
if err := assertFilePreconditions(preconditions, label); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
return writeChanges(changes, label, rename, preconditions...)
}
func commitDroidInstallation(
prepared *preparedDroidInstallation,
rename renameFunc,
) error {
if prepared == nil || prepared.paths == nil {
return fmt.Errorf("prepared Factory Droid installation is required")
}
return writeDroidChanges(
prepared.changes,
"Install Factory Droid hooks",
rename,
prepared.paths.runtimeRoot,
prepared.paths.agentDirectory,
prepared.preconditions,
)
}
func prepareDroidUninstall(
rendered []*droidRenderedDocument,
) (*preparedDroidUninstall, error) {
changes := make([]*fileChange, 0, len(rendered))
changedPaths := make([]string, 0, len(rendered))
for _, item := range rendered {
change, err := prepareRenderedDroidChange(item)
if err != nil {
return nil, err
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := make([]filePrecondition, 0, len(rendered))
for _, item := range rendered {
changed := false
for _, path := range changedPaths {
if sameDroidPath(item.document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: item.document.filePath,
label: droidSourceLabel(item.document),
snapshot: item.document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return &preparedDroidUninstall{changes, preconditions}, nil
}
func commitDroidUninstall(
prepared *preparedDroidUninstall,
rename renameFunc,
) error {
if prepared == nil {
return fmt.Errorf("prepared Factory Droid uninstall is required")
}
return writeDroidChanges(
prepared.changes,
"Uninstall Factory Droid hooks",
rename,
"",
"",
prepared.preconditions,
)
}
+122 -45
View File
@@ -1,62 +1,139 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
type droidFactoryConfigPaths struct {
directory string
root string
legacyDirectory string
legacy string
settings string
localSettings string
}
func droidFactoryPaths() (*droidFactoryConfigPaths, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
label := "Factory Droid hooks"
if rendered.document.kind == "legacy" {
label = "Factory Droid legacy hooks"
} else if rendered.document.kind == "settings" {
label = "Factory Droid settings"
directory := filepath.Join(home, ".factory")
legacyDirectory := filepath.Join(directory, "hooks")
return &droidFactoryConfigPaths{
directory: directory,
root: filepath.Join(directory, "hooks.json"),
legacyDirectory: legacyDirectory,
legacy: filepath.Join(legacyDirectory, "hooks.json"),
settings: filepath.Join(directory, "settings.json"),
localSettings: filepath.Join(directory, "settings.local.json"),
}, nil
}
func readDroidDocument(
filePath, kind, label string,
) (*droidDocument, error) {
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil || snapshot == nil {
return nil, err
}
return prepareSourceChange(
rendered.document.filePath,
label,
rendered.document.raw,
rendered.document.exists,
rendered.next,
os.FileMode(0600),
rendered.remove,
return parseDroidDocument(
true,
filePath,
kind,
snapshot.contents,
snapshot,
)
}
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
for _, contract := range contracts {
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config")
func readDroidSources() (*droidSources, error) {
paths, err := droidFactoryPaths()
if err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.directory,
"Factory Droid user configuration directory",
); err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.legacyDirectory,
"Factory Droid legacy hooks directory",
); err != nil {
return nil, err
}
root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks")
if err != nil {
return nil, err
}
if root == nil {
root, err = createOwnedDroidDocument(paths.root)
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 nil, err
}
}
return false, nil
legacy, err := readDroidDocument(paths.legacy, "legacy", "Factory Droid legacy hooks")
if err != nil {
return nil, err
}
if legacy == nil {
legacy, err = createDroidDocument(paths.legacy, "legacy", []byte("{}\n"))
if err != nil {
return nil, err
}
}
settings, err := readDroidDocument(paths.settings, "settings", "Factory Droid user settings")
if err != nil {
return nil, err
}
if settings == nil {
settings, err = createDroidDocument(paths.settings, "settings", []byte("{}\n"))
if err != nil {
return nil, err
}
}
localSettings, err := readDroidDocument(
paths.localSettings,
"local-settings",
"Factory Droid local settings",
)
if err != nil {
return nil, err
}
if localSettings == nil {
localSettings, err = createDroidDocument(
paths.localSettings,
"local-settings",
[]byte("{}\n"),
)
if err != nil {
return nil, err
}
}
policy, err := readDroidPolicy()
if err != nil {
return nil, err
}
return &droidSources{
root: root,
legacy: legacy,
settings: settings,
localSettings: localSettings,
policy: policy,
}, nil
}
func requireDroidHooksEnabled(sources *droidSources) error {
if blocked := droidHookBlocked(sources); blocked != nil {
return fmt.Errorf(
"Factory Droid user hooks are disabled by %s in %s at %s",
blocked.field,
blocked.label,
blocked.filePath,
)
}
return nil
}
+267
View File
@@ -0,0 +1,267 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
type droidPolicyLocation struct {
filePath string
label string
}
type droidPolicyLayer struct {
filePath string
label string
snapshot *managedFileSnapshot
hooksDisabled *bool
allowManagedHooksOnly *bool
showHookOutput *bool
}
type droidPolicyOrigin struct {
filePath string
label string
}
type droidPolicyState struct {
allowManagedHooksOnlyBy *droidPolicyOrigin
hooksDisabled *bool
hooksDisabledBy *droidPolicyOrigin
preconditions []filePrecondition
}
func defaultDroidManagedSettingsPath() string {
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/Factory/settings.json"
case "windows":
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
return filepath.Join(programFiles, "Factory", "settings.json")
default:
return "/etc/factory/settings.json"
}
}
var droidManagedSettingsPath = defaultDroidManagedSettingsPath
func readDroidPolicyLayer(location droidPolicyLocation) (*droidPolicyLayer, error) {
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(location.filePath),
location.label+" directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
location.filePath,
location.label,
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
layer := &droidPolicyLayer{
filePath: location.filePath,
label: location.label,
snapshot: snapshot,
}
if snapshot == nil {
return layer, nil
}
root, err := decodeJSONCObject(
snapshot.contents,
fmt.Sprintf("%s at %s", location.label, location.filePath),
true,
)
if err != nil {
return nil, err
}
layer.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", location.label)
if err != nil {
return nil, err
}
layer.allowManagedHooksOnly, err = droidOptionalBoolean(
root,
"allowManagedHooksOnly",
location.label,
)
if err != nil {
return nil, err
}
layer.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", location.label)
if err != nil {
return nil, err
}
return layer, nil
}
func droidGitRoot(start string) (string, bool, error) {
current, err := filepath.Abs(start)
if err != nil {
return "", false, fmt.Errorf("resolve Factory Droid project directory: %w", err)
}
for {
marker := filepath.Join(current, ".git")
info, inspectErr := os.Lstat(marker)
switch {
case inspectErr == nil:
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
return "", false, fmt.Errorf(
"Factory Droid project marker is not physical: %s",
marker,
)
}
return current, true, nil
case !os.IsNotExist(inspectErr):
return "", false, fmt.Errorf(
"inspect Factory Droid project marker at %s: %w",
marker,
inspectErr,
)
}
parent := filepath.Dir(current)
if parent == current {
return "", false, nil
}
current = parent
}
}
func droidProjectDirectories(root, current string) []string {
directories := []string{root}
relative, err := filepath.Rel(root, current)
if err != nil || relative == "." || relative == "" || filepath.IsAbs(relative) {
return directories
}
if relative == ".." || len(relative) > 3 && relative[:3] == ".."+string(os.PathSeparator) {
return directories
}
directory := root
for _, segment := range splitDroidPath(relative) {
directory = filepath.Join(directory, segment)
directories = append(directories, directory)
}
return directories
}
func splitDroidPath(value string) []string {
parts := make([]string, 0)
for value != "." && value != "" {
directory, file := filepath.Split(value)
if file != "" {
parts = append([]string{file}, parts...)
}
value = filepath.Clean(directory)
if value == string(os.PathSeparator) {
break
}
}
return parts
}
func droidProjectPolicyLocations() ([][2]droidPolicyLocation, error) {
current, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
current, err = filepath.Abs(current)
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
root, found, err := droidGitRoot(current)
if err != nil {
return nil, err
}
if !found {
root = current
}
directories := droidProjectDirectories(root, current)
locations := make([][2]droidPolicyLocation, 0, len(directories))
for index, directory := range directories {
scope := "project"
if index > 0 {
scope = "folder " + directory
}
factory := filepath.Join(directory, ".factory")
locations = append(locations, [2]droidPolicyLocation{
{filepath.Join(factory, "settings.json"), "Factory Droid " + scope + " settings"},
{filepath.Join(factory, "settings.local.json"), "Factory Droid " + scope + " local settings"},
})
}
return locations, nil
}
func droidScopeHooksDisabled(
settings, local *droidPolicyLayer,
) *droidPolicyLayer {
if local.hooksDisabled != nil {
return local
}
if settings.hooksDisabled != nil {
return settings
}
return nil
}
func readDroidPolicy() (*droidPolicyState, error) {
managed, err := readDroidPolicyLayer(droidPolicyLocation{
droidManagedSettingsPath(),
"Factory Droid system-managed settings",
})
if err != nil {
return nil, err
}
locations, err := droidProjectPolicyLocations()
if err != nil {
return nil, err
}
projectLayers := make([][2]*droidPolicyLayer, 0, len(locations))
for _, scope := range locations {
settings, readErr := readDroidPolicyLayer(scope[0])
if readErr != nil {
return nil, readErr
}
local, readErr := readDroidPolicyLayer(scope[1])
if readErr != nil {
return nil, readErr
}
projectLayers = append(projectLayers, [2]*droidPolicyLayer{settings, local})
}
state := &droidPolicyState{}
if managed.allowManagedHooksOnly != nil && *managed.allowManagedHooksOnly {
state.allowManagedHooksOnlyBy = &droidPolicyOrigin{managed.filePath, managed.label}
}
selected := managed
if selected.hooksDisabled == nil {
selected = nil
for _, scope := range projectLayers {
if layer := droidScopeHooksDisabled(scope[0], scope[1]); layer != nil {
selected = layer
break
}
}
}
if selected != nil {
state.hooksDisabled = selected.hooksDisabled
state.hooksDisabledBy = &droidPolicyOrigin{selected.filePath, selected.label}
}
layers := []*droidPolicyLayer{managed}
for _, scope := range projectLayers {
layers = append(layers, scope[0], scope[1])
}
state.preconditions = make([]filePrecondition, 0, len(layers))
for _, layer := range layers {
state.preconditions = append(state.preconditions, filePrecondition{
filePath: layer.filePath,
label: layer.label,
snapshot: layer.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
return state, nil
}
+124
View File
@@ -0,0 +1,124 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDroidUserSettingsDisableBeforeRuntimeCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "hooksDisabled") {
t.Fatalf("disabled hooks error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidUserLocalSettingsOverrideBaseSettings(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
localSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
if _, err := os.Stat(fixture.runtimeConfig); err != nil {
t.Fatalf("runtime config is missing: %v", err)
}
}
func TestDroidLegacyDirectFlagsRemainSafetyCompatible(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
legacy: droidJSON(map[string]any{"hooksDisabled": true, "PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooksDisabled": false}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "legacy hooks") {
t.Fatalf("legacy safety error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidProjectPolicyHasExtensionOnlyPrecedence(t *testing.T) {
t.Run("project blocks user allow", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": false}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("project policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
})
t.Run("project allows through user block", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
})
}
func TestDroidProjectLocalSettingsOverrideMatchingBase(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
projectLocalSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project local settings") {
t.Fatalf("project local policy error = %v", err)
}
}
func TestDroidProjectValuePrecedesDeeperFolderValue(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
child := filepath.Join(fixture.workspaceDir, "packages", "console")
if err := os.MkdirAll(child, 0755); err != nil {
t.Fatalf("create child workspace: %v", err)
}
writeDroidTestObject(
t,
filepath.Join(child, ".factory", "settings.json"),
map[string]any{"hooksDisabled": true},
)
if err := os.Chdir(child); err != nil {
t.Fatalf("enter child workspace: %v", err)
}
installDroidFixture(t, fixture)
}
func TestDroidSystemManagedPolicyBlocksUserHooks(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
writeDroidTestObject(
t,
fixture.systemSettingsPath,
map[string]any{"allowManagedHooksOnly": true},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "allowManagedHooksOnly") {
t.Fatalf("managed policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidMalformedReadOnlyProjectPolicyIsPreserved(t *testing.T) {
malformed := "{ malformed"
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidString(malformed),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("malformed project policy error = %v", err)
}
if readDroidTestFile(t, fixture.projectSettingsPath) != malformed {
t.Fatal("malformed read-only policy changed")
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
+227 -173
View File
@@ -1,81 +1,101 @@
package plugins
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"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,
}},
func TestDroidStatusSurfacesMalformedRuntimeIdentity(t *testing.T) {
tests := []struct {
name string
mutate func(*droidFixture)
pattern string
}{
{
"malformed config",
func(fixture *droidFixture) {
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
},
"parse Elydora runtime config",
},
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10,
}},
{
"unsupported config field",
func(fixture *droidFixture) {
config := readDroidTestObject(t, fixture.runtimeConfig)
config["future"] = true
writeDroidTestObject(t, fixture.runtimeConfig, config)
},
"unsupported field",
},
{
"noncanonical key",
func(fixture *droidFixture) {
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
},
"canonical 32-byte",
},
)
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"])
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
test.mutate(fixture)
_, err := fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("status error = %v, want %q", err, test.pattern)
}
})
}
}
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
tests := []struct {
name string
hooks *string
root *string
legacy *string
settings *string
local *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"},
{"malformed root", droidString("{ malformed"), nil, nil, nil, "parse Factory Droid hooks"},
{"non-object root", droidJSON([]any{}), nil, nil, nil, "JSON object"},
{"duplicate event", droidString(`{"hooks":{"PreToolUse":[],"PreToolUse":[]}}`), nil, nil, nil, "duplicate"},
{"null event", droidString(`{"hooks":{"PreToolUse":null}}`), nil, nil, nil, "must be an array"},
{"null group", droidString(`{"hooks":{"PreToolUse":[null]}}`), nil, nil, nil, "must be an object"},
{
"invalid matcher",
droidString(`{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`),
nil, nil, nil, "regular expression",
},
{
"empty handler command",
droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`),
nil, nil, nil, "non-empty",
},
{
"nonpositive timeout",
droidString(`{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"user","timeout":0}]}]}}`),
nil, nil, nil, "positive",
},
{"malformed legacy", nil, droidString("{ malformed"), nil, nil, "legacy hooks"},
{"null settings hooks", nil, nil, droidString(`{"hooks":null}`), nil, "JSON object"},
{"invalid local flag", nil, nil, nil, droidString(`{"hooksDisabled":"yes"}`), "boolean"},
}
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,
root: test.root,
legacy: test.legacy,
settings: test.settings,
localSettings: test.local,
})
originals := existingDroidSources(t, fixture)
err := fixture.plugin.Install(fixture.config)
@@ -87,41 +107,57 @@ func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
t.Fatalf("failed install changed %s", path)
}
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
for _, path := range []string{
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
requireMissingDroidFile(t, path)
}
})
}
}
func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) {
func TestDroidUnknownEventsAndExtensionFieldsRemainValid(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
"hooks": map[string]any{
"FutureFactoryEvent": []any{map[string]any{
"matcher": ".*",
"commandRegex": "^factory",
"owner": "user",
"hooks": []any{map[string]any{
"type": "command",
"command": "user-command",
"timeout": 1,
"future": true,
}},
}},
},
})})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
future := requireDroidObject(t, requireDroidArray(t, hooks["FutureFactoryEvent"])[0])
if future["owner"] != "user" {
t.Fatalf("future event = %#v", future)
}
}
func TestDroidInstallValidatesConfigurationBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(*droidFixture)
pattern string
}{
{"missing organization", func(f *droidFixture) { f.config.OrgID = "" }, "organization ID is required"},
{"blank key ID", func(f *droidFixture) { f.config.KID = " " }, "key ID is required"},
{"invalid private key", func(f *droidFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"invalid base URL", func(f *droidFixture) { f.config.BaseURL = "relative" }, "absolute HTTP or HTTPS"},
{"invalid agent ID", func(f *droidFixture) { f.config.AgentID = "../agent" }, "single non-empty path segment"},
{
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",
"guard outside managed directory",
func(f *droidFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js") },
"managed agent directory",
},
}
for _, test := range tests {
@@ -132,111 +168,145 @@ func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) {
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)
}
requireMissingDroidFile(t, fixture.configPath)
requireMissingDroidFile(t, fixture.runtimeConfig)
})
}
}
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) {
func TestDroidOrphanedRuntimeIsPreservedWithoutVerifiableIdentity(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 := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create orphaned runtime: %v", err)
}
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall without PATH: %v", err)
if err := os.WriteFile(fixture.guardPath, []byte("orphaned guard\n"), 0700); err != nil {
t.Fatalf("write orphaned guard: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphaned runtime error = %v", err)
}
if readDroidTestFile(t, fixture.guardPath) != "orphaned guard\n" {
t.Fatal("orphaned guard changed")
}
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
func TestDroidUninstallPreservesUsersAndExactOwnership(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
"hooks": map[string]any{"Notification": []any{}},
})})
installDroidFixture(t, fixture)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
root := readDroidTestObject(t, fixture.configPath)
hooks := requireDroidObject(t, root["hooks"])
group := droidManagedGroup(t, hooks["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"
hooks["PreToolUse"] = append(requireDroidArray(t, hooks["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, droidTestAgentID, "agent-10", 1),
"timeout": 10,
}},
},
)
writeDroidTestObject(t, fixture.configPath, root)
agentID := droidTestAgentID
if runtime.GOOS == "windows" {
agentID = strings.ToUpper(agentID)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("status error = %v", err)
if err := fixture.plugin.Uninstall(agentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
remaining := readDroidTestFile(t, fixture.configPath)
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
if !strings.Contains(remaining, marker) {
t.Fatalf("uninstall removed %q", marker)
}
}
currentHooks := droidCurrentHooks(t, fixture.configPath)
if _, exists := currentHooks["PostToolUse"]; exists {
t.Fatalf("managed PostToolUse remains: %#v", currentHooks)
}
}
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)
func TestDroidUninstallRemovesOwnedEmptyFileAndKeepsAbsence(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
requireMissingDroidFile(t, fixture.configPath)
empty := prepareDroidFixture(t, droidFixtureOptions{})
if err := empty.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall absent hooks: %v", err)
}
requireMissingDroidFile(t, empty.configPath)
}
func TestDroidInstallRejectsLinkedPathsBeforeWrites(t *testing.T) {
for _, kind := range []string{"factory", "hook", "runtime"} {
t.Run(kind, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
target := filepath.Join(t.TempDir(), kind+"-target")
if err := os.MkdirAll(target, 0755); err != nil {
t.Fatalf("create symlink target: %v", err)
}
var linkErr error
switch kind {
case "factory":
linkErr = os.MkdirAll(fixture.homeDir, 0755)
if linkErr == nil {
linkErr = os.Symlink(target, fixture.factoryDir)
}
case "hook":
if err := os.MkdirAll(fixture.factoryDir, 0755); err != nil {
t.Fatalf("create Factory directory: %v", err)
}
targetFile := filepath.Join(target, "hooks.json")
writeDroidTestObject(t, targetFile, map[string]any{"hooks": map[string]any{}})
linkErr = os.Symlink(targetFile, fixture.configPath)
case "runtime":
linkErr = os.MkdirAll(fixture.homeDir, 0755)
if linkErr == nil {
linkErr = os.Symlink(target, filepath.Join(fixture.homeDir, ".elydora"))
}
}
if linkErr != nil {
t.Skipf("symbolic links unavailable: %v", linkErr)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical") {
t.Fatalf("linked-path error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
})
}
}
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} {
for _, path := range []string{
fixture.configPath,
fixture.legacyPath,
fixture.settingsPath,
fixture.localSettingsPath,
} {
if raw, err := os.ReadFile(path); err == nil {
result[path] = string(raw)
} else if !errors.Is(err, os.ErrNotExist) {
@@ -245,19 +315,3 @@ func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string
}
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)
}
}
+251
View File
@@ -0,0 +1,251 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readDroidRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
config, err := decodeStrictJSONObject(
snapshot.contents,
fmt.Sprintf("Elydora runtime config at %s", path),
)
return config, true, err
}
func requireDroidRuntimeString(
config map[string]any,
field, configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateDroidRuntimeConfig(
config map[string]any,
expectedAgentID, configPath string,
) error {
supported := stringSet(
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireDroidRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireDroidRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Factory Droid hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireDroidRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateDroidRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, droidAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != droidAgentKey ||
!sameDroidAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Factory Droid agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validDroidContractPaths(contract droidRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameDroidPath(
contract.guardPath,
filepath.Join(agentDirectory, droidGuardScript),
) &&
sameDroidPath(
contract.auditPath,
filepath.Join(agentDirectory, droidAuditScript),
), nil
}
func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) {
validPaths, err := validDroidContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateDroidRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(droidAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(
droidAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := droidRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
}
+298 -193
View File
@@ -2,12 +2,18 @@ package plugins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
@@ -15,121 +21,14 @@ func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
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")
plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin)
if !ok || !plugin.ManagesGuardRuntime() {
t.Fatal("Factory Droid plugin is not registered as a guard runtime manager")
}
}
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{}}}),
})
func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create output pipe: %v", err)
@@ -144,120 +43,326 @@ func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) {
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)
if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") {
t.Fatalf("install output = %s", output)
}
source := readDroidTestFile(t, fixture.configPath)
if !strings.HasPrefix(source, droidOwnedFileMarker+"\n") {
t.Fatalf("managed source header is missing: %s", source)
}
hooks := droidCurrentHooks(t, fixture.configPath)
guardGroup := droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath)
auditGroup := droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath)
requireDroidNativeGroup(t, guardGroup)
requireDroidNativeGroup(t, auditGroup)
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
if runtime.GOOS == "windows" &&
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) {
t.Fatalf("Windows Factory command = %q", guardCommand)
}
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
expectedConfig := map[string]any{
"org_id": "org-1",
"agent_id": droidTestAgentID,
"kid": "kid-1",
"base_url": "http://127.0.0.1:9",
"agent_name": droidAgentKey,
"token": "token-1",
}
if !reflect.DeepEqual(runtimeConfig, expectedConfig) {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
if readDroidTestFile(t, fixture.privateKey) != droidTestPrivateKey {
t.Fatal("private key content changed")
}
if !strings.Contains(readDroidTestFile(t, fixture.guardPath), `const AGENT_NAME = "droid"`) {
t.Fatal("guard runtime identity is missing")
}
if !strings.Contains(readDroidTestFile(t, fixture.hookPath), "const NATIVE_PAYLOAD = true") {
t.Fatal("audit runtime is missing native payload mode")
}
paths := []string{
fixture.configPath,
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
}
before := snapshotDroidFiles(t, paths...)
installDroidFixture(t, fixture)
requireDroidSnapshot(t, before)
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidInstallMigratesLegacyWindowsCommands(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows migration contract")
}
fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{"hooks": map[string]any{}})})
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
group := func(script string) map[string]any {
return map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command",
"command": fmt.Sprintf(`"%s" "%s"`, nodePath, script),
"timeout": 10,
}},
}
}
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
"PreToolUse": []any{group(fixture.guardPath)},
"PostToolUse": []any{group(fixture.hookPath)},
}})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
for _, event := range droidToolEvents {
groups := requireDroidArray(t, hooks[event])
if len(groups) != 1 {
t.Fatalf("%s groups = %#v", event, groups)
}
command := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, groups[0])["hooks"])[0])["command"].(string)
if !strings.HasPrefix(command, "& '") {
t.Fatalf("migrated command = %q", command)
}
}
}
func TestDroidCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
func TestDroidRootHookFileHasWholeSourcePrecedence(t *testing.T) {
root := `{
// active root source
"hooks": {
"PreToolUse": [
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
]
}
}
`
settings := `{
// inactive settings source
"theme": "dark",
"hooks": {
"PostToolUse": [
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
]
}
}
`
fixture := prepareDroidFixture(t, droidFixtureOptions{
root: droidString(root),
settings: droidString(settings),
})
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)
if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
t.Fatal("root JSONC comment was lost")
}
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)
hooks := droidCurrentHooks(t, fixture.configPath)
pre := requireDroidArray(t, hooks["PreToolUse"])
userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"]
if userCommand != "root-user" {
t.Fatalf("root user command = %#v", userCommand)
}
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")
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
if readDroidTestFile(t, fixture.settingsPath) != settings {
t.Fatal("inactive settings source changed")
}
}
func TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) {
func TestDroidSettingsSourcePreservesFormatting(t *testing.T) {
settings := "{\r\n\t\"theme\": \"dark\",\r\n\t\"hooks\": {}\r\n}\r\n"
fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(settings)})
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 changed:\n%s", raw)
}
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
}
func TestDroidLocalSettingsContainerHasWholeSourcePrecedence(t *testing.T) {
settings := droidJSON(map[string]any{"hooks": map[string]any{"Notification": []any{}}})
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
settings: settings,
localSettings: droidJSON(map[string]any{"hooks": map[string]any{"SessionStart": []any{}}}),
})
baseBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
if readDroidTestFile(t, fixture.settingsPath) != baseBefore {
t.Fatal("base settings changed while local settings were active")
}
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.localSettingsPath)["hooks"])
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
}
func TestDroidLegacyHookFileStaysActiveUntilFactoryMigratesIt(t *testing.T) {
legacy := droidJSON(map[string]any{"PreToolUse": []any{map[string]any{
"matcher": "Read",
"hooks": []any{map[string]any{"type": "command", "command": "legacy-user"}},
}}})
settings := droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}})
fixture := prepareDroidFixture(t, droidFixtureOptions{legacy: legacy, settings: settings})
settingsBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
legacyHooks := readDroidTestObject(t, fixture.legacyPath)
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PostToolUse"], fixture.hookPath))
if readDroidTestFile(t, fixture.settingsPath) != settingsBefore {
t.Fatal("inactive settings source changed")
}
}
func TestDroidInstallCleansExactManagedHooksFromInactiveSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
"hooks": droidCurrentHooks(t, fixture.configPath),
"owner": "user",
})
installDroidFixture(t, fixture)
settings := readDroidTestObject(t, fixture.settingsPath)
if settings["owner"] != "user" {
t.Fatalf("inactive settings owner = %#v", settings["owner"])
}
hooks := requireDroidObject(t, settings["hooks"])
if _, exists := hooks["PreToolUse"]; exists {
t.Fatalf("inactive PreToolUse remains: %#v", hooks)
}
if _, exists := hooks["PostToolUse"]; exists {
t.Fatalf("inactive PostToolUse remains: %#v", hooks)
}
}
type droidAPIRequest struct {
method string
path string
body []byte
}
func TestDroidGuardBlocksAndAuditPreservesNativePayload(t *testing.T) {
requests := make([]droidAPIRequest, 0)
var requestLock sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
t.Errorf("read API request: %v", err)
}
requestLock.Lock()
requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body})
requestLock.Unlock()
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodPost {
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`))
return
}
_, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`))
}))
defer server.Close()
fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
guard := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)
audit := droidManagedHandler(t, hooks["PostToolUse"], fixture.hookPath)
writeDroidTestObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "frozen",
"cached_at": time.Now().UnixMilli(),
})
prePayload := map[string]any{
"session_id": "session-1",
"transcript_path": filepath.Join(fixture.homeDir, "transcript.jsonl"),
"cwd": fixture.workspaceDir,
"permission_mode": "auto-high",
"hook_event_name": "PreToolUse",
"tool_name": "Execute",
"tool_input": map[string]any{"command": "echo test"},
}
preRaw, _ := json.Marshal(prePayload)
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, string(preRaw))
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, `Agent "droid" is frozen`) {
t.Fatalf("guard result = %#v", guardResult)
}
postPayload := make(map[string]any, len(prePayload)+1)
for key, value := range prePayload {
postPayload[key] = value
}
postPayload["hook_event_name"] = "PostToolUse"
postPayload["tool_response"] = map[string]any{"output": "test", "success": true}
postRaw, _ := json.Marshal(postPayload)
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, string(postRaw))
if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult)
}
requestLock.Lock()
defer requestLock.Unlock()
var operation map[string]any
for _, request := range requests {
if request.method == http.MethodPost {
if err := json.Unmarshal(request.body, &operation); err != nil {
t.Fatalf("decode audit request: %v", err)
}
break
}
}
if operation == nil || !reflect.DeepEqual(operation["payload"], postPayload) {
t.Fatalf("native operation = %#v", operation)
}
if !reflect.DeepEqual(operation["subject"], map[string]any{"session_id": "session-1"}) ||
!reflect.DeepEqual(operation["action"], map[string]any{"tool": "Execute"}) {
t.Fatalf("native operation identity = %#v", operation)
}
}
func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
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)
if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil {
t.Fatalf("tamper 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)
t.Fatalf("tampered 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"
func TestDroidStatusUsesOneWholeActiveSource(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidString(hooks), settings: droidString(settings),
settings: droidJSON(map[string]any{"hooks": map[string]any{}}),
})
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)
settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
"PreToolUse": settingsHooks["PreToolUse"],
}})
status, err := fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("split-source status = %#v, %v", status, err)
}
}
func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) {
func TestDroidStatusAndUninstallDoNotRequireNodeResolution(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")
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 Factory Droid hooks: %v", err)
t.Fatalf("uninstall without PATH: %v", err)
}
requireMissingDroidFile(t, fixture.configPath)
}
+182 -76
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -12,33 +13,45 @@ import (
"testing"
)
const droidTestAgentID = "agent-1"
const (
droidTestAgentID = "agent-1"
droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
)
type droidFixtureOptions struct {
hooks *string
legacyHooks *string
settings *string
skipGuard bool
root *string
legacy *string
settings *string
localSettings *string
projectSettings *string
projectLocalSettings *string
baseURL string
agentID string
}
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
plugin *DroidPlugin
config InstallConfig
homeDir string
workspaceDir string
factoryDir string
configPath string
legacyPath string
settingsPath string
localSettingsPath string
projectSettingsPath string
projectLocalSettingsPath string
systemSettingsPath string
agentDir string
guardPath string
hookPath string
runtimeConfig string
privateKey string
}
type droidCommandResult struct {
exitCode int
stdout string
stderr string
}
@@ -57,51 +70,78 @@ func droidJSON(value any) *string {
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")
homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
workspaceDir := filepath.Join(rootDir, "workspace with spaces")
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)
localSettingsPath := filepath.Join(factoryDir, "settings.local.json")
projectSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.json")
projectLocalSettingsPath := filepath.Join(workspaceDir, ".factory", "settings.local.json")
systemSettingsPath := filepath.Join(rootDir, "managed factory", "settings.json")
agentID := options.agentID
if agentID == "" {
agentID = droidTestAgentID
}
agentDir := filepath.Join(homeDir, ".elydora", agentID)
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 err := os.MkdirAll(filepath.Join(workspaceDir, ".git"), 0755); err != nil {
t.Fatalf("create fixture workspace: %v", 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, configPath, options.root)
writeOptionalDroidFile(t, legacyPath, options.legacy)
writeOptionalDroidFile(t, settingsPath, options.settings)
writeOptionalDroidFile(t, localSettingsPath, options.localSettings)
writeOptionalDroidFile(t, projectSettingsPath, options.projectSettings)
writeOptionalDroidFile(t, projectLocalSettingsPath, options.projectLocalSettings)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
oldDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("read working directory: %v", err)
}
if err := os.Chdir(workspaceDir); err != nil {
t.Fatalf("enter fixture workspace: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(oldDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
previousManagedPath := droidManagedSettingsPath
droidManagedSettingsPath = func() string { return systemSettingsPath }
t.Cleanup(func() { droidManagedSettingsPath = previousManagedPath })
baseURL := options.baseURL
if baseURL == "" {
baseURL = "http://127.0.0.1:9"
}
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"),
plugin: &DroidPlugin{},
homeDir: homeDir,
workspaceDir: workspaceDir,
factoryDir: factoryDir,
configPath: configPath,
legacyPath: legacyPath,
settingsPath: settingsPath,
localSettingsPath: localSettingsPath,
projectSettingsPath: projectSettingsPath,
projectLocalSettingsPath: projectLocalSettingsPath,
systemSettingsPath: systemSettingsPath,
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",
AgentID: agentID,
PrivateKey: droidTestPrivateKey,
KID: "kid-1",
BaseURL: "https://api.elydora.test",
Token: "token-1",
BaseURL: baseURL,
GuardScriptPath: guardPath,
},
}
@@ -120,6 +160,11 @@ func writeOptionalDroidFile(t *testing.T, path string, source *string) {
}
}
func writeDroidTestObject(t *testing.T, path string, value any) {
t.Helper()
writeOptionalDroidFile(t, path, droidJSON(value))
}
func installDroidFixture(t *testing.T, fixture *droidFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
@@ -138,7 +183,7 @@ func readDroidTestFile(t *testing.T, path string) string {
func requireMissingDroidFile(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be missing, got %v", path, err)
}
}
@@ -146,7 +191,9 @@ func requireMissingDroidFile(t *testing.T, path string) {
func readDroidTestObject(t *testing.T, path string) map[string]any {
t.Helper()
standard, err := standardizeJSONC(
[]byte(readDroidTestFile(t, path)), "Factory Droid test source", true,
[]byte(readDroidTestFile(t, path)),
"Factory Droid test source",
true,
)
if err != nil {
t.Fatalf("standardize %s: %v", path, err)
@@ -158,6 +205,11 @@ func readDroidTestObject(t *testing.T, path string) map[string]any {
return object
}
func droidCurrentHooks(t *testing.T, path string) map[string]any {
t.Helper()
return requireDroidObject(t, readDroidTestObject(t, path)["hooks"])
}
func requireDroidObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
@@ -176,24 +228,6 @@ func requireDroidArray(t *testing.T, value any) []any {
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) {
@@ -201,7 +235,8 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
for _, handlerValue := range requireDroidArray(t, group["hooks"]) {
handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string)
if strings.Contains(command, scriptPath) {
_, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) {
return group
}
}
@@ -210,30 +245,101 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
return nil
}
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)
_, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) {
return handler
}
}
t.Fatalf("managed handler for %s not found", scriptPath)
return nil
}
func requireDroidNativeGroup(t *testing.T, group map[string]any) {
t.Helper()
if len(group) != 2 || group["matcher"] != "*" {
t.Fatalf("managed group = %#v", group)
}
handlers := requireDroidArray(t, group["hooks"])
if len(handlers) != 1 {
t.Fatalf("managed handlers = %#v", handlers)
}
handler := requireDroidObject(t, handlers[0])
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) {
t.Fatalf("managed handler = %#v", handler)
}
}
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)
process = exec.Command(
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
command,
)
} else {
process = exec.Command("sh", "-c", command)
}
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
process.Stdin = bytes.NewBufferString(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
result := droidCommandResult{stdout: stdout.String(), stderr: stderr.String()}
if err == nil {
return droidCommandResult{stderr: stderr.String()}
return result
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return droidCommandResult{exitCode: exitError.ExitCode(), stderr: stderr.String()}
result.exitCode = exitError.ExitCode()
return result
}
t.Fatalf("run Factory Droid hook command: %v", err)
return droidCommandResult{}
}
func snapshotDroidFiles(t *testing.T, paths ...string) map[string]string {
t.Helper()
result := make(map[string]string, len(paths))
for _, path := range paths {
result[path] = readDroidTestFile(t, path)
}
return result
}
func requireDroidSnapshot(t *testing.T, expected map[string]string) {
t.Helper()
for path, source := range expected {
if current := readDroidTestFile(t, path); current != source {
t.Fatalf("%s changed", path)
}
}
}
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,270 @@
package plugins
import (
"errors"
"os"
"strings"
"testing"
)
func prepareDroidTestInstallation(
t *testing.T,
fixture *droidFixture,
config InstallConfig,
) *preparedDroidInstallation {
t.Helper()
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read Factory Droid sources: %v", err)
}
paths, nodePath, err := preflightDroidInstallation(config, sources)
if err != nil {
t.Fatalf("preflight Factory Droid installation: %v", err)
}
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
if err != nil {
t.Fatalf("render Factory Droid installation: %v", err)
}
prepared, err := prepareDroidInstallation(config, sources, rendered)
if err != nil {
t.Fatalf("prepare Factory Droid installation: %v", err)
}
return prepared
}
func droidManagedPaths(fixture *droidFixture) []string {
return []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.configPath,
}
}
func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
failed := false
rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.configPath) {
failed = true
return errors.New("injected Droid hook commit failure")
}
return os.Rename(source, destination)
}
err := commitDroidInstallation(prepared, rename)
if err == nil || !strings.Contains(err.Error(), "injected Droid hook commit failure") || !failed {
t.Fatalf("transaction error = %v, failed = %v", err, failed)
}
for _, path := range droidManagedPaths(fixture) {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooks\":{\"PreToolUse\":[]},\"owner\":\"concurrent\"}\n"
if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil {
t.Fatalf("replace active hook source: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "changed during Install Factory Droid") {
t.Fatalf("concurrent source error = %v", err)
}
for path, source := range before {
if sameDroidPath(path, fixture.configPath) {
continue
}
if readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after concurrent source replacement: %s", path)
}
}
if readDroidTestFile(t, fixture.configPath) != concurrent {
t.Fatal("concurrent source was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentInactiveLocalSourceCreationIsDetected(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooksDisabled\":true}\n"
writeOptionalDroidFile(t, fixture.localSettingsPath, droidString(concurrent))
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "local settings changed during Install") {
t.Fatalf("concurrent local source error = %v", err)
}
requireDroidSnapshot(t, before)
if readDroidTestFile(t, fixture.localSettingsPath) != concurrent {
t.Fatal("concurrent local settings source was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentProjectPolicyChangeIsDetected(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
updated := fixture.config
updated.OrgID = "org-updated"
prepared := prepareDroidTestInstallation(t, fixture, updated)
concurrent := "{\"hooksDisabled\":true}\n"
if err := os.WriteFile(fixture.projectSettingsPath, []byte(concurrent), 0600); err != nil {
t.Fatalf("change project policy: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") {
t.Fatalf("concurrent project policy error = %v", err)
}
requireDroidSnapshot(t, before)
if readDroidTestFile(t, fixture.projectSettingsPath) != concurrent {
t.Fatal("concurrent project policy was overwritten")
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidConcurrentPolicyChangePreventsRuntimeDirectoryCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
if err := os.WriteFile(
fixture.projectSettingsPath,
[]byte("{\"hooksDisabled\":true}\n"),
0600,
); err != nil {
t.Fatalf("change project policy: %v", err)
}
err := commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "project settings changed during Install") {
t.Fatalf("concurrent project policy error = %v", err)
}
requireMissingDroidFile(t, fixture.agentDir)
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
before := snapshotDroidFiles(t, droidManagedPaths(fixture)...)
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read Factory Droid sources: %v", err)
}
original := readDroidTestFile(t, fixture.configPath)
if err := os.Remove(fixture.configPath); err != nil {
t.Fatalf("remove hook source: %v", err)
}
if err := os.WriteFile(fixture.configPath, []byte(original), 0600); err != nil {
t.Fatalf("replace hook source: %v", err)
}
updated := fixture.config
updated.OrgID = "org-updated"
paths, nodePath, err := preflightDroidInstallation(updated, sources)
if err != nil {
t.Fatalf("preflight stale sources: %v", err)
}
rendered, err := renderDroidInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
paths.runtimeRoot,
)
if err != nil {
t.Fatalf("render stale sources: %v", err)
}
prepared, err := prepareDroidInstallation(updated, sources, rendered)
if err != nil {
t.Fatalf("prepare stale sources: %v", err)
}
err = commitDroidInstallation(prepared, nil)
if err == nil || !strings.Contains(err.Error(), "changed during Install") {
t.Fatalf("same-content replacement error = %v", err)
}
if readDroidTestFile(t, fixture.configPath) != original {
t.Fatal("same-content replacement changed")
}
for path, source := range before {
if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after inode replacement: %s", path)
}
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
installed := droidCurrentHooks(t, fixture.configPath)
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
"hooks": installed,
"owner": "user",
})
paths := append(droidManagedPaths(fixture), fixture.settingsPath)
before := snapshotDroidFiles(t, paths...)
sources, err := readDroidSources()
if err != nil {
t.Fatalf("read uninstall sources: %v", err)
}
runtimeRoot, err := droidRuntimeRoot()
if err != nil {
t.Fatalf("resolve runtime root: %v", err)
}
rendered, err := renderDroidUninstall(sources, droidTestAgentID, runtimeRoot)
if err != nil {
t.Fatalf("render uninstall: %v", err)
}
prepared, err := prepareDroidUninstall(rendered)
if err != nil {
t.Fatalf("prepare uninstall: %v", err)
}
failed := false
rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.settingsPath) {
failed = true
return errors.New("injected Droid uninstall failure")
}
return os.Rename(source, destination)
}
err = commitDroidUninstall(prepared, rename)
if err == nil || !strings.Contains(err.Error(), "injected Droid uninstall failure") || !failed {
t.Fatalf("uninstall rollback error = %v, failed = %v", err, failed)
}
requireDroidSnapshot(t, before)
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidPreflightBlocksDisabledHooksBeforeRuntimeCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
})
preflighter, ok := any(fixture.plugin).(InstallPreflighter)
if !ok {
t.Fatal("Factory Droid plugin does not implement InstallPreflighter")
}
err := preflighter.PreflightInstall(fixture.config)
if err == nil || !strings.Contains(err.Error(), "hooksDisabled") {
t.Fatalf("preflight error = %v", err)
}
requireMissingDroidFile(t, fixture.agentDir)
}