fix(go): mirror Factory Droid 0.175 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 14:39:07 -04:00
1 parent c59fcafebb
commit aa4e2c5fd4
14 files changed
+2682 -815

No files matched your search

+1 -1
View File
@@ -96,7 +96,7 @@ Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `P
Auggie installation commits `~/.augment/settings.json`, both platform wrappers, generated runtimes, runtime config, and private key as one rollback-capable transaction. The managed `PreToolUse` guard propagates exit code `2`; `PostToolUse` preserves Auggie's complete native hook payload. System, workspace, local workspace, and alternate `--augment-cache-dir` settings remain unchanged. Run `auggie tools list` to validate the effective user configuration.
Factory Droid installation selects `PreToolUse` and `PostToolUse` independently across `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, and `~/.factory/settings.json`. Elydora preserves JSONC comments and formatting, commits runtime and hook changes as one transaction, and reports disabled hooks as unhealthy. Run `/hooks` in Droid after installation to review the effective hooks.
Factory Droid 0.175 installation follows its whole-source precedence: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Elydora writes the current `{ "hooks": { ... } }` root contract, preserves JSONC comments and formatting, accepts future hook events, and removes exact legacy Elydora commands during migration. System, project, and nested-folder settings remain read-only policy inputs; `hooksDisabled` and organization-managed hook policy block installation before runtime creation. The guard, native-payload audit runtime, strict config, canonical private key, and every affected hook document commit in one rollback-capable transaction. Windows commands use Factory's native PowerShell execution contract and propagate `$LASTEXITCODE`. Run `/hooks` in Droid after installation to review the effective local and organization-managed hooks.
Qwen Code installation writes native user hooks to `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default. Elydora follows Qwen's `.env` discovery order, preserves JSON-with-comments source formatting, keeps workspace settings read-only, and commits runtime and settings changes as one transaction. Run `/hooks` in Qwen Code after installation to review the effective hooks.
+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)
}
}
}
+85 -150
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 {
_, _, err = preflightDroidInstallation(config, sources)
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
}
}
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
prepared, err := prepareDroidUninstall(rendered)
if err != nil {
return err
}
change, changeErr := prepareRenderedDroidChange(rendered)
if changeErr != nil {
return changeErr
}
changes = append(changes, change)
}
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
}
+236 -120
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
root *droidDocument
legacy *droidDocument
settings *droidDocument
}
type droidInstallationTargets struct {
targets map[string]*droidDocument
createdRoot *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)
if hooksValue, hasHooks := root["hooks"]; hasHooks {
document.basePath = []any{"hooks"}
document.hasHooksContainer = true
document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker))
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 {
func activeDroidDocument(sources *droidSources) *droidDocument {
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.root.exists:
return sources.root
case sources.legacy.exists:
return sources.legacy
case sources.localSettings.hasHooksContainer:
return sources.localSettings
case sources.settings.hasHooksContainer:
selection.targets[event] = sources.settings
return sources.settings
default:
if selection.createdRoot == nil {
created, err := createOwnedDroidDocument(sources.rootPath)
if err != nil {
return nil, err
return sources.root
}
selection.createdRoot = created
}
selection.targets[event] = selection.createdRoot
}
}
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,7 +268,13 @@ func renderDroidDocument(
}
return removals[left].event < removals[right].event
})
for _, event := range droidToolEvents {
removedEvent := false
for _, removal := range removals {
if removal.event != event {
continue
}
removedEvent = true
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
if removal.removeGroup {
if err := editor.remove(groupPath); err != nil {
@@ -227,13 +289,12 @@ func renderDroidDocument(
}
}
}
current, err := parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack())
if err != nil {
return nil, err
if removedEvent {
current, parseErr := droidHooksFromRaw(document, editor.pack())
if parseErr != nil {
return nil, parseErr
}
if document.ownedFile {
for _, event := range droidToolEvents {
groups, exists := current.hooks[event].([]any)
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
}
+115 -94
View File
@@ -6,10 +6,10 @@ import (
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
@@ -21,18 +21,11 @@ const (
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 "", 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
}
@@ -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,
)
}
+116 -39
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 false, err
return nil, err
}
if !exists {
continue
if _, err := managedPhysicalDirectoryExists(
paths.directory,
"Factory Droid user configuration directory",
); err != nil {
return nil, err
}
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)
if _, err := managedPhysicalDirectoryExists(
paths.legacyDirectory,
"Factory Droid legacy hooks directory",
); err != nil {
return nil, 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")
root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks")
if err != nil {
return false, err
return nil, err
}
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
if root == nil {
root, err = createOwnedDroidDocument(paths.root)
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
}
@@ -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) {
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",
},
{
"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",
},
}
for _, test := range tests {
t.Run(test.name, func(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",
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)
}
})
group["owner"] = "user"
root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]),
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10,
}},
},
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10,
}},
},
)
writeDroidTestObject(t, fixture.configPath, root)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
remainingRaw := readDroidTestFile(t, fixture.configPath)
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
if !strings.Contains(remainingRaw, marker) {
t.Fatalf("uninstall removed lookalike %q", marker)
}
}
remaining := readDroidTestObject(t, fixture.configPath)
if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 {
t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"])
}
}
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
tests := []struct {
name string
hooks *string
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
}
+293 -188
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) {
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)
if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
t.Fatal("root JSONC comment was lost")
}
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)
}
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 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{
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)
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
encodedPath, err := json.Marshal(capturePath)
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.Fatalf("marshal capture path: %v", err)
t.Errorf("read API request: %v", err)
}
captureScript := "const fs = require('node:fs'); const chunks = []; " +
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture audit runtime: %v", err)
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
}
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") {
_, _ = 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 := "{\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)
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)
}
if readDroidTestFile(t, capturePath) != postPayload {
t.Fatal("audit hook changed the official Factory Droid payload")
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 TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
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)
}
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -12,13 +13,20 @@ import (
"testing"
)
const droidTestAgentID = "agent-1"
const (
droidTestAgentID = "agent-1"
droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
)
type droidFixtureOptions struct {
hooks *string
legacyHooks *string
root *string
legacy *string
settings *string
skipGuard bool
localSettings *string
projectSettings *string
projectLocalSettings *string
baseURL string
agentID string
}
type droidFixture struct {
@@ -30,6 +38,10 @@ type droidFixture struct {
configPath string
legacyPath string
settingsPath string
localSettingsPath string
projectSettingsPath string
projectLocalSettingsPath string
systemSettingsPath string
agentDir string
guardPath string
hookPath string
@@ -39,6 +51,7 @@ type droidFixture struct {
type droidCommandResult struct {
exitCode int
stdout string
stderr string
}
@@ -57,31 +70,53 @@ 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,
@@ -90,6 +125,10 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
configPath: configPath,
legacyPath: legacyPath,
settingsPath: settingsPath,
localSettingsPath: localSettingsPath,
projectSettingsPath: projectSettingsPath,
projectLocalSettingsPath: projectLocalSettingsPath,
systemSettingsPath: systemSettingsPath,
agentDir: agentDir,
guardPath: guardPath,
hookPath: hookPath,
@@ -98,10 +137,11 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
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)
}