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
+2680 -813

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. 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. 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)
}
}
}
+83 -148
View File
@@ -1,145 +1,143 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// DroidPlugin manages Factory Droid native user hooks.
type DroidPlugin struct { type DroidPlugin struct {
rename renameFunc rename renameFunc
} }
type droidRuntimeConfig struct { // ManagesGuardRuntime reports that Droid commits its guard, audit runtime,
OrgID string `json:"org_id"` // credentials, and hook documents in one transaction.
AgentID string `json:"agent_id"` func (p *DroidPlugin) ManagesGuardRuntime() bool {
KID string `json:"kid"` return true
BaseURL string `json:"base_url"`
Token string `json:"token"`
AgentName string `json:"agent_name"`
} }
func (p *DroidPlugin) Install(config InstallConfig) error { // PreflightInstall validates every hook source, policy layer, credential,
runtimeRoot, agentDirectory, err := droidAgentDirectory(config.AgentID) // matcher, and runtime identity before any write.
if err != nil { func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
return err
}
sources, err := readDroidSources() sources, err := readDroidSources()
if err != nil { if err != nil {
return err return err
} }
settings := []droidHookSettings{sources.settings.hooks} _, _, err = preflightDroidInstallation(config, sources)
if sources.primary != nil {
settings = append(settings, sources.primary.hooks)
}
if err := validateDroidRegexes(settings...); err != nil {
return err
}
expectedGuard := filepath.Join(agentDirectory, droidGuardScript)
if !sameDroidPath(config.GuardScriptPath, expectedGuard) {
return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard)
}
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
if err != nil {
return err
}
if !guardExists {
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return err
}
auditPath := filepath.Join(agentDirectory, droidAuditScript)
selection, err := selectDroidInstallationTargets(sources)
if err != nil {
return err return err
} }
func renderDroidInstallation(
sources *droidSources,
guardPath, auditPath, nodePath, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
target := activeDroidDocument(sources)
groups := map[string]map[string]any{ groups := map[string]map[string]any{
"PreToolUse": buildDroidGroup(nodePath, config.GuardScriptPath), "PreToolUse": buildDroidGroup(nodePath, guardPath),
"PostToolUse": buildDroidGroup(nodePath, auditPath), "PostToolUse": buildDroidGroup(nodePath, auditPath),
} }
documents := uniqueDroidDocuments( documents := droidInstallationDocuments(sources)
sources.primary,
droidSettingsDocument(sources.settings),
selection.targets["PreToolUse"],
selection.targets["PostToolUse"],
)
rendered := make([]*droidRenderedDocument, 0, len(documents)) rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents { for _, document := range documents {
result, renderErr := renderDroidDocument( item, err := renderDroidDocument(
document, "", runtimeRoot, droidAdditionsFor(document, selection.targets, groups), document,
"",
runtimeRoot,
droidAdditionsFor(document, target, groups),
) )
if renderErr != nil { if err != nil {
return renderErr 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 { if err != nil {
return err 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 return err
} }
fmt.Printf( rendered, err := renderDroidInstallation(
" Factory Droid: PreToolUse: %s, PostToolUse: %s\n", sources,
selection.targets["PreToolUse"].filePath, paths.guardPath,
selection.targets["PostToolUse"].filePath, paths.auditPath,
nodePath,
paths.runtimeRoot,
) )
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.") fmt.Println("Factory Droid: run /hooks to review the Elydora hook changes.")
return nil return nil
} }
func (p *DroidPlugin) Uninstall(agentID string) error { func renderDroidUninstall(
runtimeRoot, err := droidRuntimeRoot() 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 { if err != nil {
return err return nil, err
} }
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *DroidPlugin) Uninstall(agentID string) error {
sources, err := readDroidSources() sources, err := readDroidSources()
if err != nil { if err != nil {
return err return err
} }
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings)) runtimeRoot, err := droidRuntimeRoot()
changes := make([]*fileChange, 0, len(documents)) if err != nil {
for _, document := range documents { return err
rendered, renderErr := renderDroidDocument(document, agentID, runtimeRoot, nil)
if renderErr != nil {
return renderErr
} }
change, changeErr := prepareRenderedDroidChange(rendered) rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot)
if changeErr != nil { if err != nil {
return changeErr return err
} }
changes = append(changes, change) prepared, err := prepareDroidUninstall(rendered)
if err != nil {
return err
} }
return writeChanges(changes, "Write Factory Droid hook sources", p.rename) return commitDroidUninstall(prepared, p.rename)
} }
func (p *DroidPlugin) Status() (PluginStatus, error) { func (p *DroidPlugin) Status() (PluginStatus, error) {
entry := SupportedAgents[droidAgentKey] entry := SupportedAgents[droidAgentKey]
status := PluginStatus{ status := PluginStatus{AgentName: droidAgentKey, DisplayName: entry.Name}
AgentName: droidAgentKey, DisplayName: entry.Name,
}
sources, err := readDroidSources() sources, err := readDroidSources()
if err != nil { if err != nil {
return status, err return status, err
} }
status.ConfigPath = displayDroidConfigPath(sources) status.ConfigPath = displayDroidConfigPath(sources)
primary := droidHookSettings{} if droidHookBlocked(sources) != nil {
if sources.primary != nil {
primary = sources.primary.hooks
}
effective := mergeDroidSettings(primary, sources.settings.hooks)
if disabled, _ := effective["hooksDisabled"].(bool); disabled {
return status, nil return status, nil
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := droidRuntimeRoot()
if err != nil { if err != nil {
return status, err return status, err
} }
contracts := droidRuntimeContracts(effective, runtimeRoot) contracts := droidRuntimeContracts(effectiveDroidHooks(sources), runtimeRoot)
status.HookConfigured = len(contracts) > 0 status.HookConfigured = len(contracts) > 0
if !status.HookConfigured { if !status.HookConfigured {
return status, nil return status, nil
@@ -151,66 +149,3 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
status.Installed = status.HookScriptExists status.Installed = status.HookScriptExists
return status, nil 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 ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"reflect"
"sort" "sort"
) )
@@ -13,21 +13,22 @@ type droidDocument struct {
filePath string filePath string
exists bool exists bool
raw []byte raw []byte
snapshot *managedFileSnapshot
root map[string]any
hooks droidHookSettings hooks droidHookSettings
basePath []any basePath []any
hasHooksContainer bool hasHooksContainer bool
hooksDisabled *bool
showHookOutput *bool
ownedFile bool ownedFile bool
} }
type droidSources struct { type droidSources struct {
rootPath string root *droidDocument
primary *droidDocument legacy *droidDocument
settings *droidDocument settings *droidDocument
} localSettings *droidDocument
policy *droidPolicyState
type droidInstallationTargets struct {
targets map[string]*droidDocument
createdRoot *droidDocument
} }
type droidRenderedDocument struct { type droidRenderedDocument struct {
@@ -37,72 +38,39 @@ type droidRenderedDocument struct {
remove bool remove bool
} }
func droidFactoryPaths() (string, string, string, error) { type droidHookBlock struct {
home, err := os.UserHomeDir() field string
if err != nil { filePath string
return "", "", "", fmt.Errorf("resolve home directory: %w", err) label string
}
directory := filepath.Join(home, ".factory")
return filepath.Join(directory, "hooks.json"),
filepath.Join(directory, "hooks", "hooks.json"),
filepath.Join(directory, "settings.json"), nil
} }
func readDroidSources() (*droidSources, error) { func parseDroidDocument(
rootPath, legacyPath, settingsPath, err := droidFactoryPaths() exists bool,
if err != nil { filePath, kind string,
return nil, err raw []byte,
} snapshot *managedFileSnapshot,
rootRaw, rootExists, err := readOptionalFile(rootPath, "Factory Droid hooks") ) (*droidDocument, error) {
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) {
label := droidDocumentLabel(kind, filePath) label := droidDocumentLabel(kind, filePath)
root, err := decodeJSONCObject(raw, label, true) root, err := decodeJSONCObject(raw, label, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
document := &droidDocument{ 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" { if kind == "settings" || kind == "local-settings" {
hooksValue, hasHooks := root["hooks"]
document.basePath = []any{"hooks"} 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 document.hasHooksContainer = hasHooks
if !hasHooks { if !hasHooks {
document.hooks = droidHookSettings{} 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"`) document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err return document, err
} }
document.hooks, err = readDroidHookSettings(root, label) if hooksValue, hasHooks := root["hooks"]; hasHooks {
document.basePath = []any{"hooks"}
document.hasHooksContainer = true document.hasHooksContainer = true
document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)) document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err 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 { func droidDocumentLabel(kind, filePath string) string {
switch kind { switch kind {
case "settings": case "settings":
return fmt.Sprintf("Factory Droid settings at %s", filePath) return fmt.Sprintf("Factory Droid settings at %s", filePath)
case "local-settings":
return fmt.Sprintf("Factory Droid local settings at %s", filePath)
case "legacy": case "legacy":
return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath) return fmt.Sprintf("Factory Droid legacy hooks at %s", filePath)
default: 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) { func createOwnedDroidDocument(filePath string) (*droidDocument, error) {
return parseDroidDocument( return createDroidDocument(
false, filePath, "hooks", []byte(droidOwnedFileMarker+"\n{}\n"), filePath,
"hooks",
[]byte(droidOwnedFileMarker+"\n{\n \"hooks\": {}\n}\n"),
) )
} }
func selectDroidInstallationTargets(sources *droidSources) (*droidInstallationTargets, error) { func activeDroidDocument(sources *droidSources) *droidDocument {
selection := &droidInstallationTargets{targets: make(map[string]*droidDocument, len(droidToolEvents))}
for _, event := range droidToolEvents {
switch { switch {
case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event): case sources.root.exists:
selection.targets[event] = sources.primary return sources.root
case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event): case sources.legacy.exists:
selection.targets[event] = sources.settings return sources.legacy
case sources.primary != nil: case sources.localSettings.hasHooksContainer:
selection.targets[event] = sources.primary return sources.localSettings
case sources.settings.hasHooksContainer: case sources.settings.hasHooksContainer:
selection.targets[event] = sources.settings return sources.settings
default: default:
if selection.createdRoot == nil { return sources.root
created, err := createOwnedDroidDocument(sources.rootPath)
if err != nil {
return nil, err
} }
selection.createdRoot = created
}
selection.targets[event] = selection.createdRoot
}
}
return selection, nil
} }
func hasDroidHookField(settings droidHookSettings, key string) bool { func effectiveDroidHooks(sources *droidSources) droidHookSettings {
_, exists := settings[key] return activeDroidDocument(sources).hooks
return exists }
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 { func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
@@ -183,17 +236,13 @@ func uniqueDroidDocuments(documents ...*droidDocument) []*droidDocument {
} }
func droidAdditionsFor( func droidAdditionsFor(
document *droidDocument, document, target *droidDocument,
targets map[string]*droidDocument,
groups map[string]map[string]any, groups map[string]map[string]any,
) map[string]map[string]any { ) map[string]map[string]any {
additions := map[string]map[string]any{} if sameDroidPath(document.filePath, target.filePath) {
for _, event := range droidToolEvents { return groups
if sameDroidPath(targets[event].filePath, document.filePath) {
additions[event] = groups[event]
} }
} return map[string]map[string]any{}
return additions
} }
func renderDroidDocument( func renderDroidDocument(
@@ -201,7 +250,14 @@ func renderDroidDocument(
agentID, runtimeRoot string, agentID, runtimeRoot string,
additions map[string]map[string]any, additions map[string]map[string]any,
) (*droidRenderedDocument, error) { ) (*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 { if err != nil {
return nil, err return nil, err
} }
@@ -212,7 +268,13 @@ func renderDroidDocument(
} }
return removals[left].event < removals[right].event return removals[left].event < removals[right].event
}) })
for _, event := range droidToolEvents {
removedEvent := false
for _, removal := range removals { for _, removal := range removals {
if removal.event != event {
continue
}
removedEvent = true
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex) groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
if removal.removeGroup { if removal.removeGroup {
if err := editor.remove(groupPath); err != nil { 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 removedEvent {
if err != nil { current, parseErr := droidHooksFromRaw(document, editor.pack())
return nil, err if parseErr != nil {
return nil, parseErr
} }
if document.ownedFile { groups, exists := current[event].([]any)
for _, event := range droidToolEvents {
groups, exists := current.hooks[event].([]any)
if exists && len(groups) == 0 { if exists && len(groups) == 0 {
if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil { if err := editor.remove(appendDroidPath(document.basePath, event)); err != nil {
return nil, err return nil, err
@@ -246,12 +307,12 @@ func renderDroidDocument(
if !exists { if !exists {
continue continue
} }
current, err = parseDroidDocument(document.exists, document.filePath, document.kind, editor.pack()) current, parseErr := droidHooksFromRaw(document, editor.pack())
if err != nil { if parseErr != nil {
return nil, err return nil, parseErr
} }
eventPath := appendDroidPath(document.basePath, event) eventPath := appendDroidPath(document.basePath, event)
if hasDroidHookField(current.hooks, event) { if _, exists := current[event]; exists {
err = editor.appendArray(eventPath, group) err = editor.appendArray(eventPath, group)
} else { } else {
err = editor.addProperty(document.basePath, event, []any{group}) err = editor.addProperty(document.basePath, event, []any{group})
@@ -261,16 +322,77 @@ func renderDroidDocument(
} }
} }
next := editor.pack() 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 { if err != nil {
return nil, err 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{ 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 }, 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 { func appendDroidPath(base []any, parts ...any) []any {
path := make([]any, 0, len(base)+len(parts)) path := make([]any, 0, len(base)+len(parts))
path = append(path, base...) path = append(path, base...)
@@ -278,11 +400,5 @@ func appendDroidPath(base []any, parts ...any) []any {
} }
func displayDroidConfigPath(sources *droidSources) string { func displayDroidConfigPath(sources *droidSources) string {
if sources.primary != nil { return activeDroidDocument(sources).filePath
return sources.primary.filePath
}
if sources.settings.exists && sources.settings.hasHooksContainer {
return sources.settings.filePath
}
return sources.rootPath
} }
+115 -94
View File
@@ -6,10 +6,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strings" "strings"
"time" "time"
) )
@@ -21,18 +21,11 @@ const (
droidHookTimeout = float64(10) droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora" droidOwnedFileMarker = "// Managed by Elydora"
droidPOSIXApostrophe = `'"'"'` droidPOSIXApostrophe = `'"'"'`
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
) )
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} 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 droidHookSettings map[string]any
type droidManagedRemoval struct { type droidManagedRemoval struct {
@@ -58,30 +51,20 @@ func readDroidHookSettings(value any, label string) (droidHookSettings, error) {
if !ok || object == nil { if !ok || object == nil {
return nil, fmt.Errorf("%s must contain a JSON object", label) return nil, fmt.Errorf("%s must contain a JSON object", label)
} }
settings := make(droidHookSettings, len(object)) hooks := make(droidHookSettings, len(object))
for key, item := range object { for event, item := range object {
if droidFlagNames[key] {
if _, ok := item.(bool); !ok {
return nil, fmt.Errorf(`%s field %q must be a boolean`, label, key)
}
settings[key] = item
continue
}
if !droidEventNames[key] {
return nil, fmt.Errorf(`%s contains unsupported field %q`, label, key)
}
groups, ok := item.([]any) groups, ok := item.([]any)
if !ok { 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 { 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 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 { 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" { if handler["type"] != "command" {
return fmt.Errorf(`%s type must be "command"`, location) return fmt.Errorf(`%s type must be "command"`, location)
} }
if _, ok := handler["command"].(string); !ok { command, ok := handler["command"].(string)
return fmt.Errorf("%s command must be a string", location) if !ok || strings.TrimSpace(command) == "" {
return fmt.Errorf("%s command must be a non-empty string", location)
} }
if timeout, exists := handler["timeout"]; exists { if timeout, exists := handler["timeout"]; exists {
number, ok := timeout.(float64) number, ok := timeout.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) { if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf("%s timeout must be a finite number", location) return fmt.Errorf("%s timeout must be a positive finite number", location)
} }
} }
return nil return nil
@@ -137,19 +121,19 @@ func validateDroidRegexes(settings ...droidHookSettings) error {
entries := make([]droidRegexEntry, 0) entries := make([]droidRegexEntry, 0)
for _, source := range settings { for _, source := range settings {
for event, value := range source { for event, value := range source {
if droidFlagNames[event] { groups, _ := value.([]any)
continue for index, groupValue := range groups {
}
for index, groupValue := range value.([]any) {
group := groupValue.(map[string]any) group := groupValue.(map[string]any)
if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" { if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" {
entries = append(entries, droidRegexEntry{ 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 { if expression, ok := group["commandRegex"].(string); ok {
entries = append(entries, droidRegexEntry{ 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 { func buildDroidCommand(nodePath, scriptPath string) string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return quoteDroidWindowsArgument(nodePath) + " " + quoteDroidWindowsArgument(scriptPath) return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
} }
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
} }
func quoteDroidWindowsArgument(value string) string { func quoteDroidPowerShellArgument(value string) string {
quoted := quoteWindowsArgument(value) return "'" + strings.ReplaceAll(value, "'", "''") + "'"
if strings.HasPrefix(quoted, `"`) {
return quoted
}
return `"` + quoted + `"`
} }
func buildDroidGroup(nodePath, scriptPath string) map[string]any { 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) { func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
reader := readDroidPOSIXArgument if runtime.GOOS != "windows" {
if runtime.GOOS == "windows" { return parseDroidPOSIXCommand(command)
reader = readDroidWindowsArgument
} }
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] != ' ' { if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false 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 != "" return executable, script, ok && end == len(command) && executable != "" && script != ""
} }
func readDroidWindowsArgument(command string, start int) (string, int, bool) { func parseDroidPowerShellCommand(command string) (string, string, bool) {
if start >= len(command) || command[start] != '"' { 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 return "", start, false
} }
var value strings.Builder var value strings.Builder
for index := start + 1; index < len(command); index++ { for index := start + 1; index < len(command); index++ {
if command[index] == '\\' && index+1 < len(command) && command[index+1] == '"' { if command[index] != '\'' {
value.WriteByte('"') value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++ index++
continue continue
} }
if command[index] == '"' {
return value.String(), index + 1, true return value.String(), index + 1, true
} }
value.WriteByte(command[index])
}
return "", start, false 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) { func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' { if start >= len(command) || command[start] != '\'' {
return "", start, false return "", start, false
@@ -293,14 +316,14 @@ func sameDroidAgentID(left, right string) bool {
} }
func droidRuntimeRoot() (string, error) { func droidRuntimeRoot() (string, error) {
home, err := os.UserHomeDir() return AgentRuntimeRoot()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".elydora"), nil
} }
func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) { func managedDroidAgentID(
handler map[string]any,
scriptName, runtimeRoot string,
includeLegacy ...bool,
) (string, bool) {
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout { if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout {
return "", false return "", false
} }
@@ -308,9 +331,9 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string)
if !ok { if !ok {
return "", false return "", false
} }
executable, scriptPath, ok := parseDroidCommand(command) executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || !isDroidNodeExecutable(executable) || if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
!sameDroidFileName(filepath.Base(scriptPath), scriptName) { !isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) {
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
@@ -323,10 +346,7 @@ func managedDroidAgentID(handler map[string]any, scriptName, runtimeRoot string)
func isDroidNodeExecutable(path string) bool { func isDroidNodeExecutable(path string) bool {
name := filepath.Base(path) name := filepath.Base(path)
if runtime.GOOS == "windows" { return name == "node" || strings.EqualFold(name, "node.exe")
return strings.EqualFold(name, "node.exe")
}
return name == "node"
} }
func sameDroidFileName(left, right string) bool { func sameDroidFileName(left, right string) bool {
@@ -347,7 +367,9 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
handlers := group["hooks"].([]any) handlers := group["hooks"].([]any)
indexes := make([]int, 0) indexes := make([]int, 0)
for handlerIndex, handlerValue := range handlers { 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)) { if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) {
indexes = append(indexes, handlerIndex) indexes = append(indexes, handlerIndex)
} }
@@ -357,8 +379,11 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
} }
_, hasMatcher := group["matcher"] _, hasMatcher := group["matcher"]
_, hasHooks := group["hooks"] _, hasHooks := group["hooks"]
exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) exactGroup := len(group) == 2 && hasMatcher && hasHooks &&
removals = append(removals, droidManagedRemoval{contract.event, groupIndex, indexes, exactGroup}) group["matcher"] == "*" && len(indexes) == len(handlers)
removals = append(removals, droidManagedRemoval{
contract.event, groupIndex, indexes, exactGroup,
})
} }
} }
return removals return removals
@@ -367,20 +392,24 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract { func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract {
guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot) guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot)
audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot) audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot)
contracts := make([]droidRuntimeContract, 0) guardIDs := make([]string, 0, len(guards))
for guardID := range guards { for agentID := range guards {
matched := false guardIDs = append(guardIDs, agentID)
}
sort.Strings(guardIDs)
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
for _, guardID := range guardIDs {
for auditID := range audits { for auditID := range audits {
if sameDroidAgentID(guardID, auditID) { if !sameDroidAgentID(guardID, auditID) {
matched = true continue
break
} }
}
if matched {
root := filepath.Join(runtimeRoot, guardID) root := filepath.Join(runtimeRoot, guardID)
contracts = append(contracts, droidRuntimeContract{ 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 return contracts
@@ -390,22 +419,14 @@ func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot stri
ids := map[string]bool{} ids := map[string]bool{}
groups, _ := settings[event].([]any) groups, _ := settings[event].([]any)
for _, groupValue := range groups { for _, groupValue := range groups {
for _, handlerValue := range groupValue.(map[string]any)["hooks"].([]any) { group := groupValue.(map[string]any)
if agentID, ok := managedDroidAgentID(handlerValue.(map[string]any), script, runtimeRoot); ok { handlers := group["hooks"].([]any)
ids[agentID] = true 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 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 package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
) )
func prepareRenderedDroidChange(rendered *droidRenderedDocument) (*fileChange, error) { type droidFactoryConfigPaths struct {
if rendered == nil || !rendered.changed { directory string
return nil, nil root string
legacyDirectory string
legacy string
settings string
localSettings string
} }
label := "Factory Droid hooks"
if rendered.document.kind == "legacy" { func droidFactoryPaths() (*droidFactoryConfigPaths, error) {
label = "Factory Droid legacy hooks" home, err := os.UserHomeDir()
} else if rendered.document.kind == "settings" { if err != nil {
label = "Factory Droid settings" return nil, fmt.Errorf("resolve home directory: %w", err)
} }
return prepareSourceChange( directory := filepath.Join(home, ".factory")
rendered.document.filePath, legacyDirectory := filepath.Join(directory, "hooks")
label, return &droidFactoryConfigPaths{
rendered.document.raw, directory: directory,
rendered.document.exists, root: filepath.Join(directory, "hooks.json"),
rendered.next, legacyDirectory: legacyDirectory,
os.FileMode(0600), legacy: filepath.Join(legacyDirectory, "hooks.json"),
rendered.remove, 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 parseDroidDocument(
true,
filePath,
kind,
snapshot.contents,
snapshot,
) )
} }
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) { func readDroidSources() (*droidSources, error) {
for _, contract := range contracts { paths, err := droidFactoryPaths()
configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json")
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config")
if err != nil { if err != nil {
return false, err return nil, err
} }
if !exists { if _, err := managedPhysicalDirectoryExists(
continue paths.directory,
"Factory Droid user configuration directory",
); err != nil {
return nil, err
} }
var config map[string]any if _, err := managedPhysicalDirectoryExists(
if err := json.Unmarshal(raw, &config); err != nil { paths.legacyDirectory,
return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) "Factory Droid legacy hooks directory",
); err != nil {
return nil, err
} }
agentID, ok := config["agent_id"].(string) root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks")
if !ok || config["agent_name"] != droidAgentKey || !sameDroidAgentID(agentID, contract.agentID) {
continue
}
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
if err != nil { 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 { if err != nil {
return false, err return nil, err
}
if guardExists && auditExists {
return true, nil
} }
} }
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 package plugins
import ( import (
"encoding/json"
"errors" "errors"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "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{}) fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
root := readDroidTestObject(t, fixture.configPath) test.mutate(fixture)
group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath) _, err := fixture.plugin.Status()
command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string) if err == nil || !strings.Contains(err.Error(), test.pattern) {
group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{ t.Fatalf("status error = %v, want %q", err, test.pattern)
"type": "command", "command": "user-command", }
}) })
group["owner"] = "user"
root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]),
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10,
}},
},
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10,
}},
},
)
writeDroidTestObject(t, fixture.configPath, root)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
remainingRaw := readDroidTestFile(t, fixture.configPath)
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
if !strings.Contains(remainingRaw, marker) {
t.Fatalf("uninstall removed lookalike %q", marker)
}
}
remaining := readDroidTestObject(t, fixture.configPath)
if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 {
t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"])
} }
} }
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) { func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
hooks *string root *string
legacy *string legacy *string
settings *string settings *string
local *string
pattern string pattern string
}{ }{
{"malformed hooks", droidString("{ malformed"), nil, nil, "parse Factory Droid hooks"}, {"malformed root", droidString("{ malformed"), nil, nil, nil, "parse Factory Droid hooks"},
{"non-object hooks", droidJSON([]any{}), nil, nil, "JSON object"}, {"non-object root", droidJSON([]any{}), nil, nil, nil, "JSON object"},
{"duplicate event", droidString(`{ "PreToolUse": [], "PreToolUse": [] }`), nil, nil, "duplicate"}, {"duplicate event", droidString(`{"hooks":{"PreToolUse":[],"PreToolUse":[]}}`), nil, nil, nil, "duplicate"},
{"nested duplicate", droidString(`{ "PreToolUse": [{ "hooks": [], "hooks": [] }] }`), nil, nil, "duplicate"}, {"null event", droidString(`{"hooks":{"PreToolUse":null}}`), nil, nil, nil, "must be an array"},
{"null event", droidString(`{ "PreToolUse": null }`), nil, nil, "must be an array"}, {"null group", droidString(`{"hooks":{"PreToolUse":[null]}}`), nil, nil, nil, "must be an object"},
{"null group", droidString(`{ "PreToolUse": [null] }`), nil, nil, "must be an object"}, {
{"invalid matcher", droidString(`{ "PreToolUse": [{ "matcher": "[", "hooks": [] }] }`), nil, nil, "regular expression"}, "invalid matcher",
{"invalid handler", droidString(`{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": 1 }] }] }`), nil, nil, "must be a string"}, droidString(`{"hooks":{"PreToolUse":[{"matcher":"[","hooks":[]}]}}`),
{"invalid flag", droidString(`{ "hooksDisabled": "yes" }`), nil, nil, "must be a boolean"}, nil, nil, nil, "regular expression",
{"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"}, "empty handler command",
{"null settings hooks", nil, nil, droidString(`{ "hooks": null }`), "JSON object"}, 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{ 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) originals := existingDroidSources(t, fixture)
err := fixture.plugin.Install(fixture.config) err := fixture.plugin.Install(fixture.config)
@@ -87,41 +107,57 @@ func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
t.Fatalf("failed install changed %s", path) 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) 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 { tests := []struct {
name string name string
mutate func(*droidFixture) mutate func(*droidFixture)
pattern string 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", "guard outside managed directory",
mutate: func(fixture *droidFixture) { func(f *droidFixture) { f.config.GuardScriptPath = filepath.Join(f.homeDir, "guard.js") },
if err := os.Remove(fixture.guardPath); err != nil { "managed agent directory",
t.Fatalf("remove guard: %v", err)
}
},
pattern: "guard runtime is missing",
},
{
name: "invalid agent id",
mutate: func(fixture *droidFixture) {
fixture.config.AgentID = "../agent"
},
pattern: "single non-empty path segment",
},
{
name: "guard outside managed directory",
mutate: func(fixture *droidFixture) {
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js")
},
pattern: "managed agent directory",
}, },
} }
for _, test := range tests { for _, test := range tests {
@@ -132,111 +168,145 @@ func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), test.pattern) { if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("install error = %v, want substring %q", err, 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, fixture.configPath)
requireMissingDroidFile(t, path) requireMissingDroidFile(t, fixture.runtimeConfig)
}
}) })
} }
} }
func TestDroidTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) { func TestDroidOrphanedRuntimeIsPreservedWithoutVerifiableIdentity(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
originalRoot := readDroidTestFile(t, fixture.configPath)
originalSettings := readDroidTestFile(t, fixture.settingsPath)
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.settingsPath) {
failed = true
return errors.New("simulated settings commit failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "Write Factory Droid installation") || !failed {
t.Fatalf("transaction error = %v, failed = %v", err, failed)
}
if readDroidTestFile(t, fixture.configPath) != originalRoot ||
readDroidTestFile(t, fixture.settingsPath) != originalSettings {
t.Fatal("transaction rollback did not restore hook sources")
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidTransactionDetectsExternalChangesAndPreservesThem(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
external := "{\n \"external\": true,\n \"hooks\": { \"PostToolUse\": [] }\n}\n"
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated {
mutated = true
if err := os.WriteFile(fixture.settingsPath, []byte(external), 0600); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("external-change error = %v", err)
}
if readDroidTestFile(t, fixture.settingsPath) != external {
t.Fatal("transaction rollback overwrote an external settings change")
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidRecoveryCommandsDoNotRequireNodeResolution(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{}) fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture) if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Setenv("PATH", "") t.Fatalf("create orphaned runtime: %v", err)
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 { if err := os.WriteFile(fixture.guardPath, []byte("orphaned guard\n"), 0700); err != nil {
t.Fatalf("uninstall without PATH: %v", err) 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) requireMissingDroidFile(t, fixture.configPath)
} }
func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) { func TestDroidUninstallPreservesUsersAndExactOwnership(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{}) fixture := prepareDroidFixture(t, droidFixtureOptions{root: droidJSON(map[string]any{
"hooks": map[string]any{"Notification": []any{}},
})})
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { root := readDroidTestObject(t, fixture.configPath)
t.Fatalf("corrupt runtime config: %v", err) 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") { if err := fixture.plugin.Uninstall(agentID); err != nil {
t.Fatalf("status error = %v", err) 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) { func TestDroidUninstallRemovesOwnedEmptyFileAndKeepsAbsence(t *testing.T) {
t.Helper() fixture := prepareDroidFixture(t, droidFixtureOptions{})
encoded, err := json.MarshalIndent(value, "", " ") installDroidFixture(t, fixture)
if err != nil { if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("encode %s: %v", path, err) t.Fatalf("uninstall Factory Droid hooks: %v", err)
} }
if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil { requireMissingDroidFile(t, fixture.configPath)
t.Fatalf("write %s: %v", path, err)
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 { func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string {
t.Helper() t.Helper()
result := map[string]string{} 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 { if raw, err := os.ReadFile(path); err == nil {
result[path] = string(raw) result[path] = string(raw)
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
@@ -245,19 +315,3 @@ func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string
} }
return result 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 ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"runtime" "runtime"
"strings" "strings"
"sync"
"testing" "testing"
"time"
) )
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) { 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" { if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" {
t.Fatalf("Factory Droid registry entry = %#v", entry) t.Fatalf("Factory Droid registry entry = %#v", entry)
} }
if _, ok := NewPlugin(droidAgentKey).(*DroidPlugin); !ok { plugin, ok := NewPlugin(droidAgentKey).(*DroidPlugin)
t.Fatal("Factory Droid plugin is not registered") if !ok || !plugin.ManagesGuardRuntime() {
t.Fatal("Factory Droid plugin is not registered as a guard runtime manager")
} }
} }
func TestDroidInstallPreservesJSONCAndUsesPerEventPrecedence(t *testing.T) { func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
hooks := `{ fixture := prepareDroidFixture(t, droidFixtureOptions{})
// root hook source
"PreToolUse": [
// keep root group comment
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
],
"Notification": []
}
`
settings := `{
// general setting
"theme": "dark",
"hooks": {
// settings fallback event
"PostToolUse": [
// keep settings group comment
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
],
"showHookOutput": true,
},
}
`
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidString(hooks), settings: droidString(settings),
})
installDroidFixture(t, fixture)
firstRoot := readDroidTestFile(t, fixture.configPath)
firstSettings := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
if readDroidTestFile(t, fixture.configPath) != firstRoot ||
readDroidTestFile(t, fixture.settingsPath) != firstSettings {
t.Fatal("idempotent install changed Factory Droid hook sources")
}
for _, marker := range []string{"root hook source", "keep root group comment"} {
if !strings.Contains(firstRoot, marker) {
t.Fatalf("root JSONC comment %q was lost", marker)
}
}
for _, marker := range []string{"general setting", "settings fallback event", "keep settings group comment"} {
if !strings.Contains(firstSettings, marker) {
t.Fatalf("settings JSONC comment %q was lost", marker)
}
}
root := readDroidTestObject(t, fixture.configPath)
userSettings := readDroidTestObject(t, fixture.settingsPath)
preGroups := requireDroidArray(t, root["PreToolUse"])
if len(preGroups) != 2 || requireDroidObject(t, requireDroidArray(t,
requireDroidObject(t, preGroups[0])["hooks"])[0])["command"] != "root-user" {
t.Fatalf("root PreToolUse groups = %#v", preGroups)
}
if _, exists := root["PostToolUse"]; exists {
t.Fatalf("PostToolUse leaked into root source: %#v", root)
}
if userSettings["theme"] != "dark" {
t.Fatalf("settings theme = %#v", userSettings["theme"])
}
settingsHooks := requireDroidObject(t, userSettings["hooks"])
postGroups := requireDroidArray(t, settingsHooks["PostToolUse"])
if len(postGroups) != 2 {
t.Fatalf("settings PostToolUse groups = %#v", postGroups)
}
if _, exists := settingsHooks["PreToolUse"]; exists {
t.Fatalf("PreToolUse leaked into settings source: %#v", settingsHooks)
}
for _, item := range []struct {
groups any
path string
}{{preGroups, fixture.guardPath}, {postGroups, fixture.hookPath}} {
handler := droidManagedHandler(t, item.groups, item.path)
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) {
t.Fatalf("managed handler = %#v", handler)
}
}
requireMissingDroidFile(t, filepath.Join(fixture.workspaceDir, ".factory", "hooks.json"))
}
func TestDroidInstallKeepsActiveLegacySource(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
legacyHooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
droidManagedHandler(t, readDroidTestObject(t, fixture.legacyPath)["PreToolUse"], fixture.guardPath)
settings := readDroidTestObject(t, fixture.settingsPath)
droidManagedHandler(t, requireDroidObject(t, settings["hooks"])["PostToolUse"], fixture.hookPath)
}
func TestDroidInstallReusesSettingsContainerAndFormatting(t *testing.T) {
source := "{\r\n\t\"owner\": \"user\",\r\n\t\"hooks\": {}\r\n}\r\n"
fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(source)})
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
raw := readDroidTestFile(t, fixture.settingsPath)
if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") ||
!strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") {
t.Fatalf("settings formatting was not preserved:\n%s", raw)
}
if readDroidTestObject(t, fixture.settingsPath)["owner"] != "user" {
t.Fatal("settings owner was lost")
}
}
func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
reader, writer, err := os.Pipe() reader, writer, err := os.Pipe()
if err != nil { if err != nil {
t.Fatalf("create output pipe: %v", err) t.Fatalf("create output pipe: %v", err)
@@ -144,120 +43,326 @@ func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) {
if installErr != nil || closeErr != nil || readErr != nil { if installErr != nil || closeErr != nil || readErr != nil {
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr) t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
} }
text := string(output) if !strings.Contains(string(output), fixture.configPath) || !strings.Contains(string(output), "run /hooks") {
for _, marker := range []string{fixture.configPath, fixture.settingsPath, "PreToolUse", "PostToolUse", "/hooks"} { t.Fatalf("install output = %s", output)
if !strings.Contains(text, marker) { }
t.Fatalf("install output is missing %q: %s", marker, text) 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{}) fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
capturePath := filepath.Join(t.TempDir(), "captured-event.json") writeDroidTestObject(t, fixture.settingsPath, map[string]any{
encodedPath, err := json.Marshal(capturePath) "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 { 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 = []; " + requestLock.Lock()
"process.stdin.on('data', chunk => chunks.push(chunk)); " + requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body})
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" requestLock.Unlock()
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { writer.Header().Set("Content-Type", "application/json")
t.Fatalf("write capture audit runtime: %v", err) if request.Method == http.MethodPost {
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`))
return
} }
root := readDroidTestObject(t, fixture.configPath) _, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`))
guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath) }))
audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath) defer server.Close()
prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}` fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL})
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, prePayload) installDroidFixture(t, fixture)
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { 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) t.Fatalf("guard result = %#v", guardResult)
} }
postPayload := "{\n \"session_id\": \"session-1\",\n \"hook_event_name\": \"PostToolUse\",\n \"tool_response\": {\"success\": true}\n}" postPayload := make(map[string]any, len(prePayload)+1)
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, postPayload) 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 { if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult) t.Fatalf("audit result = %#v", auditResult)
} }
if readDroidTestFile(t, capturePath) != postPayload { requestLock.Lock()
t.Fatal("audit hook changed the official Factory Droid payload") 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) { func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{ fixture := prepareDroidFixture(t, droidFixtureOptions{})
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
status, err := fixture.plugin.Status() status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err) t.Fatalf("installed status = %#v, %v", status, err)
} }
if err := os.Remove(fixture.hookPath); err != nil { if err := os.WriteFile(fixture.hookPath, []byte("tampered\n"), 0700); err != nil {
t.Fatalf("remove audit runtime: %v", err) t.Fatalf("tamper audit runtime: %v", err)
} }
status, err = fixture.plugin.Status() status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("missing-runtime status = %#v, %v", status, err) t.Fatalf("tampered status = %#v, %v", status, err)
}
root := readDroidTestObject(t, fixture.configPath)
root["hooksDisabled"] = true
disabled, err := json.MarshalIndent(root, "", " ")
if err != nil {
t.Fatalf("encode disabled hooks: %v", err)
}
if err := os.WriteFile(fixture.configPath, append(disabled, '\n'), 0600); err != nil {
t.Fatalf("disable hooks: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("disabled status = %#v, %v", status, err)
} }
} }
func TestDroidUninstallPreservesUserSourcesAndExactOwnership(t *testing.T) { func TestDroidStatusUsesOneWholeActiveSource(t *testing.T) {
hooks := "{\n // keep root comment\n \"PreToolUse\": []\n}\n"
settings := "{\n \"theme\": \"dark\",\n \"hooks\": {\n" +
" // keep settings comment\n \"PostToolUse\": []\n }\n}\n"
fixture := prepareDroidFixture(t, droidFixtureOptions{ fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidString(hooks), settings: droidString(settings), settings: droidJSON(map[string]any{"hooks": map[string]any{}}),
}) })
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
if err := fixture.plugin.Uninstall("agent-10"); err != nil { settingsHooks := requireDroidObject(t, readDroidTestObject(t, fixture.settingsPath)["hooks"])
t.Fatalf("uninstall lookalike agent: %v", err) writeDroidTestObject(t, fixture.configPath, map[string]any{"hooks": map[string]any{
} "PreToolUse": settingsHooks["PreToolUse"],
droidManagedHandler(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"], fixture.guardPath) }})
agentID := droidTestAgentID status, err := fixture.plugin.Status()
if runtime.GOOS == "windows" { if err != nil || status.HookConfigured || status.Installed {
agentID = "AGENT-1" t.Fatalf("split-source status = %#v, %v", status, err)
}
if err := fixture.plugin.Uninstall(agentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
rootRaw := readDroidTestFile(t, fixture.configPath)
settingsRaw := readDroidTestFile(t, fixture.settingsPath)
if !strings.Contains(rootRaw, "keep root comment") || !strings.Contains(settingsRaw, "keep settings comment") {
t.Fatal("uninstall removed user JSONC comments")
}
if len(requireDroidArray(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"])) != 0 {
t.Fatal("uninstall removed the user-owned empty event key")
}
currentSettings := readDroidTestObject(t, fixture.settingsPath)
if currentSettings["theme"] != "dark" ||
len(requireDroidArray(t, requireDroidObject(t, currentSettings["hooks"])["PostToolUse"])) != 0 {
t.Fatalf("settings after uninstall = %#v", currentSettings)
} }
} }
func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) { func TestDroidStatusAndUninstallDoNotRequireNodeResolution(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{}) fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture) installDroidFixture(t, fixture)
if !strings.HasPrefix(readDroidTestFile(t, fixture.configPath), droidOwnedFileMarker) { t.Setenv("PATH", "")
t.Fatal("created hook source is missing its ownership marker") 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 { 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) requireMissingDroidFile(t, fixture.configPath)
} }
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -12,13 +13,20 @@ import (
"testing" "testing"
) )
const droidTestAgentID = "agent-1" const (
droidTestAgentID = "agent-1"
droidTestPrivateKey = "DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0"
)
type droidFixtureOptions struct { type droidFixtureOptions struct {
hooks *string root *string
legacyHooks *string legacy *string
settings *string settings *string
skipGuard bool localSettings *string
projectSettings *string
projectLocalSettings *string
baseURL string
agentID string
} }
type droidFixture struct { type droidFixture struct {
@@ -30,6 +38,10 @@ type droidFixture struct {
configPath string configPath string
legacyPath string legacyPath string
settingsPath string settingsPath string
localSettingsPath string
projectSettingsPath string
projectLocalSettingsPath string
systemSettingsPath string
agentDir string agentDir string
guardPath string guardPath string
hookPath string hookPath string
@@ -39,6 +51,7 @@ type droidFixture struct {
type droidCommandResult struct { type droidCommandResult struct {
exitCode int exitCode int
stdout string
stderr string stderr string
} }
@@ -57,31 +70,53 @@ func droidJSON(value any) *string {
func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture { func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixture {
t.Helper() t.Helper()
rootDir := t.TempDir() rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote") homeDir := filepath.Join(rootDir, "home with spaces and 'quote %DROID%")
workspaceDir := filepath.Join(rootDir, "workspace") workspaceDir := filepath.Join(rootDir, "workspace with spaces")
factoryDir := filepath.Join(homeDir, ".factory") factoryDir := filepath.Join(homeDir, ".factory")
configPath := filepath.Join(factoryDir, "hooks.json") configPath := filepath.Join(factoryDir, "hooks.json")
legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json") legacyPath := filepath.Join(factoryDir, "hooks", "hooks.json")
settingsPath := filepath.Join(factoryDir, "settings.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) guardPath := filepath.Join(agentDir, droidGuardScript)
hookPath := filepath.Join(agentDir, droidAuditScript) hookPath := filepath.Join(agentDir, droidAuditScript)
for _, directory := range []string{workspaceDir, agentDir} { if err := os.MkdirAll(filepath.Join(workspaceDir, ".git"), 0755); err != nil {
if err := os.MkdirAll(directory, 0755); err != nil { t.Fatalf("create fixture workspace: %v", err)
t.Fatalf("create fixture directory %s: %v", directory, err)
} }
} writeOptionalDroidFile(t, configPath, options.root)
if !options.skipGuard { writeOptionalDroidFile(t, legacyPath, options.legacy)
guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"
if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
}
writeOptionalDroidFile(t, configPath, options.hooks)
writeOptionalDroidFile(t, legacyPath, options.legacyHooks)
writeOptionalDroidFile(t, settingsPath, options.settings) 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("HOME", homeDir)
t.Setenv("USERPROFILE", 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{ return &droidFixture{
plugin: &DroidPlugin{}, plugin: &DroidPlugin{},
homeDir: homeDir, homeDir: homeDir,
@@ -90,6 +125,10 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
configPath: configPath, configPath: configPath,
legacyPath: legacyPath, legacyPath: legacyPath,
settingsPath: settingsPath, settingsPath: settingsPath,
localSettingsPath: localSettingsPath,
projectSettingsPath: projectSettingsPath,
projectLocalSettingsPath: projectLocalSettingsPath,
systemSettingsPath: systemSettingsPath,
agentDir: agentDir, agentDir: agentDir,
guardPath: guardPath, guardPath: guardPath,
hookPath: hookPath, hookPath: hookPath,
@@ -98,10 +137,11 @@ func prepareDroidFixture(t *testing.T, options droidFixtureOptions) *droidFixtur
config: InstallConfig{ config: InstallConfig{
AgentName: droidAgentKey, AgentName: droidAgentKey,
OrgID: "org-1", OrgID: "org-1",
AgentID: droidTestAgentID, AgentID: agentID,
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", PrivateKey: droidTestPrivateKey,
KID: "kid-1", KID: "kid-1",
BaseURL: "https://api.elydora.test", Token: "token-1",
BaseURL: baseURL,
GuardScriptPath: guardPath, 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) { func installDroidFixture(t *testing.T, fixture *droidFixture) {
t.Helper() t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil { 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) { func requireMissingDroidFile(t *testing.T, path string) {
t.Helper() 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) 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 { func readDroidTestObject(t *testing.T, path string) map[string]any {
t.Helper() t.Helper()
standard, err := standardizeJSONC( standard, err := standardizeJSONC(
[]byte(readDroidTestFile(t, path)), "Factory Droid test source", true, []byte(readDroidTestFile(t, path)),
"Factory Droid test source",
true,
) )
if err != nil { if err != nil {
t.Fatalf("standardize %s: %v", path, err) t.Fatalf("standardize %s: %v", path, err)
@@ -158,6 +205,11 @@ func readDroidTestObject(t *testing.T, path string) map[string]any {
return object 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 { func requireDroidObject(t *testing.T, value any) map[string]any {
t.Helper() t.Helper()
object, ok := value.(map[string]any) object, ok := value.(map[string]any)
@@ -176,24 +228,6 @@ func requireDroidArray(t *testing.T, value any) []any {
return array 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 { func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[string]any {
t.Helper() t.Helper()
for _, groupValue := range requireDroidArray(t, groupsValue) { 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"]) { for _, handlerValue := range requireDroidArray(t, group["hooks"]) {
handler := requireDroidObject(t, handlerValue) handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string) command, _ := handler["command"].(string)
if strings.Contains(command, scriptPath) { _, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) {
return group return group
} }
} }
@@ -210,30 +245,101 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
return nil 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 { func runDroidCommand(t *testing.T, command, homeDir, payload string) droidCommandResult {
t.Helper() t.Helper()
var process *exec.Cmd var process *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
commandFile := filepath.Join(t.TempDir(), "run-droid-hook.cmd") process = exec.Command(
if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { "powershell.exe",
t.Fatalf("write command file: %v", err) "-NoLogo",
} "-NoProfile",
process = exec.Command("cmd.exe", "/d", "/c", commandFile) "-NonInteractive",
"-Command",
command,
)
} else { } else {
process = exec.Command("sh", "-c", command) process = exec.Command("sh", "-c", command)
} }
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
process.Stdin = bytes.NewBufferString(payload) process.Stdin = bytes.NewBufferString(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
process.Stdout = &stdout
process.Stderr = &stderr process.Stderr = &stderr
err := process.Run() err := process.Run()
result := droidCommandResult{stdout: stdout.String(), stderr: stderr.String()}
if err == nil { if err == nil {
return droidCommandResult{stderr: stderr.String()} return result
} }
var exitError *exec.ExitError var exitError *exec.ExitError
if errors.As(err, &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) t.Fatalf("run Factory Droid hook command: %v", err)
return droidCommandResult{} 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)
}