fix(droid): align hooks with Factory 0.175

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

No files matched your search

+3 -2
View File
@@ -38,8 +38,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence. - Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON.
- Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources. - Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources.
- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review. - Factory Droid `droid@0.175.0` selects one complete user hook source in this order: `~/.factory/hooks.json`, legacy `~/.factory/hooks/hooks.json`, `~/.factory/settings.local.json` `hooks`, then `~/.factory/settings.json` `hooks`. Write the current root `{ "hooks": { ... } }` container, accept unknown future event arrays, preserve JSONC syntax, and remove exact Elydora entries from every discovered user source during migration or uninstall.
- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files. - Resolve Factory `hooksDisabled` through system, project, nested-folder, and matching `settings.local.json` extension-only precedence before user settings. Treat system `allowManagedHooksOnly` as an installation gate, snapshot every read-only policy layer as a transaction precondition, and direct users to `/hooks` for local and organization-managed review.
- Generate Factory Droid guard, strict config, canonical private key, and native-payload audit runtime with every affected hook document in one rollback-capable transaction. Windows commands use Factory's native PowerShell contract with explicit `$LASTEXITCODE` propagation. Healthy status requires one exact current `PreToolUse`/`PostToolUse` pair, strict runtime identity, physical files, and exact generated runtime sources.
- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review. - Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review.
- Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files. - Commit Qwen Code runtime config, private key, audit runtime, and user settings in one rollback-capable transaction. Status requires enabled `PreToolUse` and `PostToolUse` hooks with matching managed runtime files.
+1 -1
View File
@@ -99,7 +99,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)
}
}
}
+86 -151
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 { return err
settings = append(settings, sources.primary.hooks) }
}
if err := validateDroidRegexes(settings...); err != nil { func renderDroidInstallation(
return err sources *droidSources,
} guardPath, auditPath, nodePath, runtimeRoot string,
expectedGuard := filepath.Join(agentDirectory, droidGuardScript) ) ([]*droidRenderedDocument, error) {
if !sameDroidPath(config.GuardScriptPath, expectedGuard) { target := activeDroidDocument(sources)
return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard)
}
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
if err != nil {
return err
}
if !guardExists {
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return err
}
auditPath := filepath.Join(agentDirectory, droidAuditScript)
selection, err := selectDroidInstallationTargets(sources)
if err != nil {
return err
}
groups := map[string]map[string]any{ 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,
) )
fmt.Println(" Factory Droid: run /hooks to review the Elydora hook changes.") if err != nil {
return err
}
prepared, err := prepareDroidInstallation(config, sources, rendered)
if err != nil {
return err
}
if err := commitDroidInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("Factory Droid hooks: %s\n", activeDroidDocument(sources).filePath)
fmt.Println("Factory Droid: run /hooks to review the Elydora hook changes.")
return nil return nil
} }
func renderDroidUninstall(
sources *droidSources,
agentID, runtimeRoot string,
) ([]*droidRenderedDocument, error) {
documents := droidSourceDocuments(sources)
rendered := make([]*droidRenderedDocument, 0, len(documents))
for _, document := range documents {
item, err := renderDroidDocument(
document,
agentID,
runtimeRoot,
map[string]map[string]any{},
)
if err != nil {
return nil, err
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *DroidPlugin) Uninstall(agentID string) error { func (p *DroidPlugin) Uninstall(agentID string) error {
sources, err := readDroidSources()
if err != nil {
return err
}
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := droidRuntimeRoot()
if err != nil { if err != nil {
return err return err
} }
sources, err := readDroidSources() rendered, err := renderDroidUninstall(sources, agentID, runtimeRoot)
if err != nil { if err != nil {
return err return err
} }
documents := uniqueDroidDocuments(sources.primary, droidSettingsDocument(sources.settings)) prepared, err := prepareDroidUninstall(rendered)
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)
if changeErr != nil {
return changeErr
}
changes = append(changes, change)
} }
return writeChanges(changes, "Write Factory Droid hook sources", p.rename) return commitDroidUninstall(prepared, p.rename)
} }
func (p *DroidPlugin) Status() (PluginStatus, error) { 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
}
+254 -138
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.hasHooksContainer = true document.basePath = []any{"hooks"}
document.ownedFile = bytes.HasPrefix(raw, []byte(droidOwnedFileMarker)) document.hasHooksContainer = true
document.hooks, err = readDroidHookSettings(hooksValue, label+` field "hooks"`)
return document, err
}
document.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", label)
if err != nil {
return nil, err
}
document.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", label)
if err != nil {
return nil, err
}
direct := make(map[string]any, len(root))
for key, value := range root {
if key != "hooksDisabled" && key != "showHookOutput" {
direct[key] = value
}
}
document.hooks, err = readDroidHookSettings(direct, label)
return document, err 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))} switch {
for _, event := range droidToolEvents { case sources.root.exists:
switch { return sources.root
case sources.primary != nil && hasDroidHookField(sources.primary.hooks, event): case sources.legacy.exists:
selection.targets[event] = sources.primary return sources.legacy
case sources.settings.hasHooksContainer && hasDroidHookField(sources.settings.hooks, event): case sources.localSettings.hasHooksContainer:
selection.targets[event] = sources.settings return sources.localSettings
case sources.primary != nil: case sources.settings.hasHooksContainer:
selection.targets[event] = sources.primary return sources.settings
case sources.settings.hasHooksContainer: default:
selection.targets[event] = sources.settings return sources.root
default:
if selection.createdRoot == nil {
created, err := createOwnedDroidDocument(sources.rootPath)
if err != nil {
return nil, err
}
selection.createdRoot = created
}
selection.targets[event] = selection.createdRoot
}
} }
return selection, nil
} }
func hasDroidHookField(settings droidHookSettings, key string) bool { 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 additions return map[string]map[string]any{}
} }
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,28 +268,33 @@ func renderDroidDocument(
} }
return removals[left].event < removals[right].event return removals[left].event < removals[right].event
}) })
for _, removal := range removals { for _, event := range droidToolEvents {
groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex) removedEvent := false
if removal.removeGroup { for _, removal := range removals {
if err := editor.remove(groupPath); err != nil { if removal.event != event {
return nil, err continue
} }
continue removedEvent = true
} groupPath := appendDroidPath(document.basePath, removal.event, removal.groupIndex)
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes))) if removal.removeGroup {
for _, handlerIndex := range removal.handlerIndexes { if err := editor.remove(groupPath); err != nil {
if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil { return nil, err
return nil, err }
continue
}
sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes)))
for _, handlerIndex := range removal.handlerIndexes {
if err := editor.remove(appendDroidPath(groupPath, "hooks", handlerIndex)); err != nil {
return nil, err
}
} }
} }
} if removedEvent {
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
} }
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
} }
+123 -102
View File
@@ -6,33 +6,26 @@ 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"
) )
const ( const (
droidAgentKey = "droid" droidAgentKey = "droid"
droidGuardScript = "guard.js" droidGuardScript = "guard.js"
droidAuditScript = "hook.js" droidAuditScript = "hook.js"
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
}
+400
View File
@@ -0,0 +1,400 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type droidRuntimePaths struct {
runtimeRoot string
agentDirectory string
guardPath string
auditPath string
}
type preparedDroidInstallation struct {
changes []*fileChange
preconditions []filePrecondition
paths *droidRuntimePaths
}
type preparedDroidUninstall struct {
changes []*fileChange
preconditions []filePrecondition
}
func droidSourceLabel(document *droidDocument) string {
switch document.kind {
case "settings":
return "Factory Droid user settings"
case "local-settings":
return "Factory Droid local settings"
case "legacy":
return "Factory Droid legacy hooks"
default:
return "Factory Droid user hooks"
}
}
func validateDroidInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != droidAgentKey {
return fmt.Errorf(
"Factory Droid installation requires agent name %s",
droidAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) {
if err := validateDroidInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &droidRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
guardPath: filepath.Join(agentDirectory, droidGuardScript),
auditPath: filepath.Join(agentDirectory, droidAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameDroidPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameDroidPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightDroidInstallation(
config InstallConfig,
sources *droidSources,
) (*droidRuntimePaths, string, error) {
if sources == nil || sources.root == nil || sources.policy == nil {
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
}
if err := requireDroidHooksEnabled(sources); err != nil {
return nil, "", err
}
hooks := make([]droidHookSettings, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
hooks = append(hooks, document.hooks)
}
if err := validateDroidRegexes(hooks...); err != nil {
return nil, "", err
}
paths, err := droidAgentPaths(config)
if err != nil {
return nil, "", err
}
if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Factory Droid hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil
}
func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID,
"agent_id": config.AgentID,
"kid": config.KID,
"base_url": config.BaseURL,
"agent_name": droidAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareRenderedDroidChange(
rendered *droidRenderedDocument,
) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSnapshotSourceChange(
rendered.document.filePath,
droidSourceLabel(rendered.document),
rendered.document.snapshot,
rendered.next,
0600,
rendered.remove,
)
}
func validateDroidRenderedSet(
sources *droidSources,
rendered []*droidRenderedDocument,
) error {
expected := droidInstallationDocuments(sources)
if len(rendered) != len(expected) {
return fmt.Errorf("Factory Droid rendered source set is incomplete")
}
for _, document := range expected {
matches := 0
for _, item := range rendered {
if item != nil && item.document != nil &&
sameDroidPath(item.document.filePath, document.filePath) {
matches++
}
}
if matches != 1 {
return fmt.Errorf("Factory Droid rendered source set contains unexpected paths")
}
}
return nil
}
func droidSourcePreconditions(
sources *droidSources,
changedPaths []string,
) []filePrecondition {
preconditions := make([]filePrecondition, 0, len(droidSourceDocuments(sources)))
for _, document := range droidSourceDocuments(sources) {
changed := false
for _, path := range changedPaths {
if sameDroidPath(document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: document.filePath,
label: droidSourceLabel(document),
snapshot: document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return preconditions
}
func prepareDroidInstallation(
config InstallConfig,
sources *droidSources,
rendered []*droidRenderedDocument,
) (*preparedDroidInstallation, error) {
paths, _, err := preflightDroidInstallation(config, sources)
if err != nil {
return nil, err
}
if err := validateDroidRenderedSet(sources, rendered); err != nil {
return nil, err
}
runtimeConfig, err := buildDroidRuntimeConfig(config)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath,
"Elydora guard runtime",
[]byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")),
0700,
},
{filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
changedPaths := make([]string, 0, len(rendered))
for _, document := range rendered {
change, changeErr := prepareRenderedDroidChange(document)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := droidSourcePreconditions(sources, changedPaths)
preconditions = append(preconditions, sources.policy.preconditions...)
return &preparedDroidInstallation{
changes: changes,
preconditions: preconditions,
paths: paths,
}, nil
}
func writeDroidChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory string,
preconditions []filePrecondition,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...)
}
if err := assertFilePreconditions(preconditions, label); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
return writeChanges(changes, label, rename, preconditions...)
}
func commitDroidInstallation(
prepared *preparedDroidInstallation,
rename renameFunc,
) error {
if prepared == nil || prepared.paths == nil {
return fmt.Errorf("prepared Factory Droid installation is required")
}
return writeDroidChanges(
prepared.changes,
"Install Factory Droid hooks",
rename,
prepared.paths.runtimeRoot,
prepared.paths.agentDirectory,
prepared.preconditions,
)
}
func prepareDroidUninstall(
rendered []*droidRenderedDocument,
) (*preparedDroidUninstall, error) {
changes := make([]*fileChange, 0, len(rendered))
changedPaths := make([]string, 0, len(rendered))
for _, item := range rendered {
change, err := prepareRenderedDroidChange(item)
if err != nil {
return nil, err
}
changes = append(changes, change)
if change != nil {
changedPaths = append(changedPaths, change.filePath)
}
}
preconditions := make([]filePrecondition, 0, len(rendered))
for _, item := range rendered {
changed := false
for _, path := range changedPaths {
if sameDroidPath(item.document.filePath, path) {
changed = true
break
}
}
if !changed {
preconditions = append(preconditions, filePrecondition{
filePath: item.document.filePath,
label: droidSourceLabel(item.document),
snapshot: item.document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return &preparedDroidUninstall{changes, preconditions}, nil
}
func commitDroidUninstall(
prepared *preparedDroidUninstall,
rename renameFunc,
) error {
if prepared == nil {
return fmt.Errorf("prepared Factory Droid uninstall is required")
}
return writeDroidChanges(
prepared.changes,
"Uninstall Factory Droid hooks",
rename,
"",
"",
prepared.preconditions,
)
}
+122 -45
View File
@@ -1,62 +1,139 @@
package plugins 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
}
func droidFactoryPaths() (*droidFactoryConfigPaths, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
} }
label := "Factory Droid hooks" directory := filepath.Join(home, ".factory")
if rendered.document.kind == "legacy" { legacyDirectory := filepath.Join(directory, "hooks")
label = "Factory Droid legacy hooks" return &droidFactoryConfigPaths{
} else if rendered.document.kind == "settings" { directory: directory,
label = "Factory Droid settings" root: filepath.Join(directory, "hooks.json"),
legacyDirectory: legacyDirectory,
legacy: filepath.Join(legacyDirectory, "hooks.json"),
settings: filepath.Join(directory, "settings.json"),
localSettings: filepath.Join(directory, "settings.local.json"),
}, nil
}
func readDroidDocument(
filePath, kind, label string,
) (*droidDocument, error) {
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil || snapshot == nil {
return nil, err
} }
return prepareSourceChange( return parseDroidDocument(
rendered.document.filePath, true,
label, filePath,
rendered.document.raw, kind,
rendered.document.exists, snapshot.contents,
rendered.next, snapshot,
os.FileMode(0600),
rendered.remove,
) )
} }
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") if err != nil {
raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.directory,
"Factory Droid user configuration directory",
); err != nil {
return nil, err
}
if _, err := managedPhysicalDirectoryExists(
paths.legacyDirectory,
"Factory Droid legacy hooks directory",
); err != nil {
return nil, err
}
root, err := readDroidDocument(paths.root, "hooks", "Factory Droid user hooks")
if err != nil {
return nil, err
}
if root == nil {
root, err = createOwnedDroidDocument(paths.root)
if err != nil { if err != nil {
return false, err return nil, err
}
if !exists {
continue
}
var config map[string]any
if err := json.Unmarshal(raw, &config); err != nil {
return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err)
}
agentID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != droidAgentKey || !sameDroidAgentID(agentID, contract.agentID) {
continue
}
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
if err != nil {
return false, err
}
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
if err != nil {
return false, err
}
if guardExists && auditExists {
return true, nil
} }
} }
return false, nil legacy, err := readDroidDocument(paths.legacy, "legacy", "Factory Droid legacy hooks")
if err != nil {
return nil, err
}
if legacy == nil {
legacy, err = createDroidDocument(paths.legacy, "legacy", []byte("{}\n"))
if err != nil {
return nil, err
}
}
settings, err := readDroidDocument(paths.settings, "settings", "Factory Droid user settings")
if err != nil {
return nil, err
}
if settings == nil {
settings, err = createDroidDocument(paths.settings, "settings", []byte("{}\n"))
if err != nil {
return nil, err
}
}
localSettings, err := readDroidDocument(
paths.localSettings,
"local-settings",
"Factory Droid local settings",
)
if err != nil {
return nil, err
}
if localSettings == nil {
localSettings, err = createDroidDocument(
paths.localSettings,
"local-settings",
[]byte("{}\n"),
)
if err != nil {
return nil, err
}
}
policy, err := readDroidPolicy()
if err != nil {
return nil, err
}
return &droidSources{
root: root,
legacy: legacy,
settings: settings,
localSettings: localSettings,
policy: policy,
}, nil
}
func requireDroidHooksEnabled(sources *droidSources) error {
if blocked := droidHookBlocked(sources); blocked != nil {
return fmt.Errorf(
"Factory Droid user hooks are disabled by %s in %s at %s",
blocked.field,
blocked.label,
blocked.filePath,
)
}
return nil
} }
+267
View File
@@ -0,0 +1,267 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
type droidPolicyLocation struct {
filePath string
label string
}
type droidPolicyLayer struct {
filePath string
label string
snapshot *managedFileSnapshot
hooksDisabled *bool
allowManagedHooksOnly *bool
showHookOutput *bool
}
type droidPolicyOrigin struct {
filePath string
label string
}
type droidPolicyState struct {
allowManagedHooksOnlyBy *droidPolicyOrigin
hooksDisabled *bool
hooksDisabledBy *droidPolicyOrigin
preconditions []filePrecondition
}
func defaultDroidManagedSettingsPath() string {
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/Factory/settings.json"
case "windows":
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
return filepath.Join(programFiles, "Factory", "settings.json")
default:
return "/etc/factory/settings.json"
}
}
var droidManagedSettingsPath = defaultDroidManagedSettingsPath
func readDroidPolicyLayer(location droidPolicyLocation) (*droidPolicyLayer, error) {
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(location.filePath),
location.label+" directory",
); err != nil {
return nil, err
}
snapshot, err := readManagedFile(
location.filePath,
location.label,
maxManagedSourceBytes,
)
if err != nil {
return nil, err
}
layer := &droidPolicyLayer{
filePath: location.filePath,
label: location.label,
snapshot: snapshot,
}
if snapshot == nil {
return layer, nil
}
root, err := decodeJSONCObject(
snapshot.contents,
fmt.Sprintf("%s at %s", location.label, location.filePath),
true,
)
if err != nil {
return nil, err
}
layer.hooksDisabled, err = droidOptionalBoolean(root, "hooksDisabled", location.label)
if err != nil {
return nil, err
}
layer.allowManagedHooksOnly, err = droidOptionalBoolean(
root,
"allowManagedHooksOnly",
location.label,
)
if err != nil {
return nil, err
}
layer.showHookOutput, err = droidOptionalBoolean(root, "showHookOutput", location.label)
if err != nil {
return nil, err
}
return layer, nil
}
func droidGitRoot(start string) (string, bool, error) {
current, err := filepath.Abs(start)
if err != nil {
return "", false, fmt.Errorf("resolve Factory Droid project directory: %w", err)
}
for {
marker := filepath.Join(current, ".git")
info, inspectErr := os.Lstat(marker)
switch {
case inspectErr == nil:
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
return "", false, fmt.Errorf(
"Factory Droid project marker is not physical: %s",
marker,
)
}
return current, true, nil
case !os.IsNotExist(inspectErr):
return "", false, fmt.Errorf(
"inspect Factory Droid project marker at %s: %w",
marker,
inspectErr,
)
}
parent := filepath.Dir(current)
if parent == current {
return "", false, nil
}
current = parent
}
}
func droidProjectDirectories(root, current string) []string {
directories := []string{root}
relative, err := filepath.Rel(root, current)
if err != nil || relative == "." || relative == "" || filepath.IsAbs(relative) {
return directories
}
if relative == ".." || len(relative) > 3 && relative[:3] == ".."+string(os.PathSeparator) {
return directories
}
directory := root
for _, segment := range splitDroidPath(relative) {
directory = filepath.Join(directory, segment)
directories = append(directories, directory)
}
return directories
}
func splitDroidPath(value string) []string {
parts := make([]string, 0)
for value != "." && value != "" {
directory, file := filepath.Split(value)
if file != "" {
parts = append([]string{file}, parts...)
}
value = filepath.Clean(directory)
if value == string(os.PathSeparator) {
break
}
}
return parts
}
func droidProjectPolicyLocations() ([][2]droidPolicyLocation, error) {
current, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
current, err = filepath.Abs(current)
if err != nil {
return nil, fmt.Errorf("resolve Factory Droid working directory: %w", err)
}
root, found, err := droidGitRoot(current)
if err != nil {
return nil, err
}
if !found {
root = current
}
directories := droidProjectDirectories(root, current)
locations := make([][2]droidPolicyLocation, 0, len(directories))
for index, directory := range directories {
scope := "project"
if index > 0 {
scope = "folder " + directory
}
factory := filepath.Join(directory, ".factory")
locations = append(locations, [2]droidPolicyLocation{
{filepath.Join(factory, "settings.json"), "Factory Droid " + scope + " settings"},
{filepath.Join(factory, "settings.local.json"), "Factory Droid " + scope + " local settings"},
})
}
return locations, nil
}
func droidScopeHooksDisabled(
settings, local *droidPolicyLayer,
) *droidPolicyLayer {
if local.hooksDisabled != nil {
return local
}
if settings.hooksDisabled != nil {
return settings
}
return nil
}
func readDroidPolicy() (*droidPolicyState, error) {
managed, err := readDroidPolicyLayer(droidPolicyLocation{
droidManagedSettingsPath(),
"Factory Droid system-managed settings",
})
if err != nil {
return nil, err
}
locations, err := droidProjectPolicyLocations()
if err != nil {
return nil, err
}
projectLayers := make([][2]*droidPolicyLayer, 0, len(locations))
for _, scope := range locations {
settings, readErr := readDroidPolicyLayer(scope[0])
if readErr != nil {
return nil, readErr
}
local, readErr := readDroidPolicyLayer(scope[1])
if readErr != nil {
return nil, readErr
}
projectLayers = append(projectLayers, [2]*droidPolicyLayer{settings, local})
}
state := &droidPolicyState{}
if managed.allowManagedHooksOnly != nil && *managed.allowManagedHooksOnly {
state.allowManagedHooksOnlyBy = &droidPolicyOrigin{managed.filePath, managed.label}
}
selected := managed
if selected.hooksDisabled == nil {
selected = nil
for _, scope := range projectLayers {
if layer := droidScopeHooksDisabled(scope[0], scope[1]); layer != nil {
selected = layer
break
}
}
}
if selected != nil {
state.hooksDisabled = selected.hooksDisabled
state.hooksDisabledBy = &droidPolicyOrigin{selected.filePath, selected.label}
}
layers := []*droidPolicyLayer{managed}
for _, scope := range projectLayers {
layers = append(layers, scope[0], scope[1])
}
state.preconditions = make([]filePrecondition, 0, len(layers))
for _, layer := range layers {
state.preconditions = append(state.preconditions, filePrecondition{
filePath: layer.filePath,
label: layer.label,
snapshot: layer.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
return state, nil
}
+124
View File
@@ -0,0 +1,124 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDroidUserSettingsDisableBeforeRuntimeCreation(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "hooksDisabled") {
t.Fatalf("disabled hooks error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidUserLocalSettingsOverrideBaseSettings(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
localSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
if _, err := os.Stat(fixture.runtimeConfig); err != nil {
t.Fatalf("runtime config is missing: %v", err)
}
}
func TestDroidLegacyDirectFlagsRemainSafetyCompatible(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
legacy: droidJSON(map[string]any{"hooksDisabled": true, "PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooksDisabled": false}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "legacy hooks") {
t.Fatalf("legacy safety error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidProjectPolicyHasExtensionOnlyPrecedence(t *testing.T) {
t.Run("project blocks user allow", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": false}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("project policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
})
t.Run("project allows through user block", func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
settings: droidJSON(map[string]any{"hooksDisabled": true}),
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
installDroidFixture(t, fixture)
})
}
func TestDroidProjectLocalSettingsOverrideMatchingBase(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
projectLocalSettings: droidJSON(map[string]any{"hooksDisabled": true}),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project local settings") {
t.Fatalf("project local policy error = %v", err)
}
}
func TestDroidProjectValuePrecedesDeeperFolderValue(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidJSON(map[string]any{"hooksDisabled": false}),
})
child := filepath.Join(fixture.workspaceDir, "packages", "console")
if err := os.MkdirAll(child, 0755); err != nil {
t.Fatalf("create child workspace: %v", err)
}
writeDroidTestObject(
t,
filepath.Join(child, ".factory", "settings.json"),
map[string]any{"hooksDisabled": true},
)
if err := os.Chdir(child); err != nil {
t.Fatalf("enter child workspace: %v", err)
}
installDroidFixture(t, fixture)
}
func TestDroidSystemManagedPolicyBlocksUserHooks(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
writeDroidTestObject(
t,
fixture.systemSettingsPath,
map[string]any{"allowManagedHooksOnly": true},
)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "allowManagedHooksOnly") {
t.Fatalf("managed policy error = %v", err)
}
requireMissingDroidFile(t, fixture.runtimeConfig)
}
func TestDroidMalformedReadOnlyProjectPolicyIsPreserved(t *testing.T) {
malformed := "{ malformed"
fixture := prepareDroidFixture(t, droidFixtureOptions{
projectSettings: droidString(malformed),
})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "project settings") {
t.Fatalf("malformed project policy error = %v", err)
}
if readDroidTestFile(t, fixture.projectSettingsPath) != malformed {
t.Fatal("malformed read-only policy changed")
}
requireMissingDroidFile(t, fixture.runtimeConfig)
requireMissingDroidFile(t, fixture.configPath)
}
+227 -173
View File
@@ -1,81 +1,101 @@
package plugins 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) {
fixture := prepareDroidFixture(t, droidFixtureOptions{}) tests := []struct {
installDroidFixture(t, fixture) name string
root := readDroidTestObject(t, fixture.configPath) mutate func(*droidFixture)
group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath) pattern string
command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string) }{
group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{ {
"type": "command", "command": "user-command", "malformed config",
}) func(fixture *droidFixture) {
group["owner"] = "user" if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]), t.Fatalf("corrupt runtime config: %v", err)
map[string]any{ }
"matcher": "*", },
"hooks": []any{map[string]any{ "parse Elydora runtime config",
"type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10,
}},
}, },
map[string]any{ {
"matcher": "*", "unsupported config field",
"hooks": []any{map[string]any{ func(fixture *droidFixture) {
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10, config := readDroidTestObject(t, fixture.runtimeConfig)
}}, config["future"] = true
writeDroidTestObject(t, fixture.runtimeConfig, config)
},
"unsupported field",
},
{
"noncanonical key",
func(fixture *droidFixture) {
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
},
"canonical 32-byte",
}, },
)
writeDroidTestObject(t, fixture.configPath, root)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
} }
remainingRaw := readDroidTestFile(t, fixture.configPath) for _, test := range tests {
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} { t.Run(test.name, func(t *testing.T) {
if !strings.Contains(remainingRaw, marker) { fixture := prepareDroidFixture(t, droidFixtureOptions{})
t.Fatalf("uninstall removed lookalike %q", marker) installDroidFixture(t, fixture)
} test.mutate(fixture)
} _, err := fixture.plugin.Status()
remaining := readDroidTestObject(t, fixture.configPath) if err == nil || !strings.Contains(err.Error(), test.pattern) {
if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 { t.Fatalf("status error = %v, want %q", err, test.pattern)
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
}
+298 -193
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) {
fixture := prepareDroidFixture(t, droidFixtureOptions{}) 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) installDroidFixture(t, fixture)
capturePath := filepath.Join(t.TempDir(), "captured-event.json") if !strings.Contains(readDroidTestFile(t, fixture.configPath), "active root source") {
encodedPath, err := json.Marshal(capturePath) t.Fatal("root JSONC comment was lost")
if err != nil {
t.Fatalf("marshal capture path: %v", err)
} }
captureScript := "const fs = require('node:fs'); const chunks = []; " + hooks := droidCurrentHooks(t, fixture.configPath)
"process.stdin.on('data', chunk => chunks.push(chunk)); " + pre := requireDroidArray(t, hooks["PreToolUse"])
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" userCommand := requireDroidObject(t, requireDroidArray(t, requireDroidObject(t, pre[0])["hooks"])[0])["command"]
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { if userCommand != "root-user" {
t.Fatalf("write capture audit runtime: %v", err) t.Fatalf("root user command = %#v", userCommand)
} }
root := readDroidTestObject(t, fixture.configPath) requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath) requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath) if readDroidTestFile(t, fixture.settingsPath) != settings {
prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}` t.Fatal("inactive settings source changed")
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, prePayload)
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard result = %#v", guardResult)
}
postPayload := "{\n \"session_id\": \"session-1\",\n \"hook_event_name\": \"PostToolUse\",\n \"tool_response\": {\"success\": true}\n}"
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, postPayload)
if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult)
}
if readDroidTestFile(t, capturePath) != postPayload {
t.Fatal("audit hook changed the official Factory Droid payload")
} }
} }
func TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) { 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{ fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}), settings: settings,
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}), localSettings: droidJSON(map[string]any{"hooks": map[string]any{"SessionStart": []any{}}}),
}) })
baseBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
if readDroidTestFile(t, fixture.settingsPath) != baseBefore {
t.Fatal("base settings changed while local settings were active")
}
hooks := requireDroidObject(t, readDroidTestObject(t, fixture.localSettingsPath)["hooks"])
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, hooks["PostToolUse"], fixture.hookPath))
}
func TestDroidLegacyHookFileStaysActiveUntilFactoryMigratesIt(t *testing.T) {
legacy := droidJSON(map[string]any{"PreToolUse": []any{map[string]any{
"matcher": "Read",
"hooks": []any{map[string]any{"type": "command", "command": "legacy-user"}},
}}})
settings := droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}})
fixture := prepareDroidFixture(t, droidFixtureOptions{legacy: legacy, settings: settings})
settingsBefore := readDroidTestFile(t, fixture.settingsPath)
installDroidFixture(t, fixture)
requireMissingDroidFile(t, fixture.configPath)
legacyHooks := readDroidTestObject(t, fixture.legacyPath)
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PreToolUse"], fixture.guardPath))
requireDroidNativeGroup(t, droidManagedGroup(t, legacyHooks["PostToolUse"], fixture.hookPath))
if readDroidTestFile(t, fixture.settingsPath) != settingsBefore {
t.Fatal("inactive settings source changed")
}
}
func TestDroidInstallCleansExactManagedHooksFromInactiveSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
writeDroidTestObject(t, fixture.settingsPath, map[string]any{
"hooks": droidCurrentHooks(t, fixture.configPath),
"owner": "user",
})
installDroidFixture(t, fixture)
settings := readDroidTestObject(t, fixture.settingsPath)
if settings["owner"] != "user" {
t.Fatalf("inactive settings owner = %#v", settings["owner"])
}
hooks := requireDroidObject(t, settings["hooks"])
if _, exists := hooks["PreToolUse"]; exists {
t.Fatalf("inactive PreToolUse remains: %#v", hooks)
}
if _, exists := hooks["PostToolUse"]; exists {
t.Fatalf("inactive PostToolUse remains: %#v", hooks)
}
}
type droidAPIRequest struct {
method string
path string
body []byte
}
func TestDroidGuardBlocksAndAuditPreservesNativePayload(t *testing.T) {
requests := make([]droidAPIRequest, 0)
var requestLock sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
t.Errorf("read API request: %v", err)
}
requestLock.Lock()
requests = append(requests, droidAPIRequest{request.Method, request.URL.Path, body})
requestLock.Unlock()
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodPost {
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"operation":{"accepted":true}}`))
return
}
_, _ = writer.Write([]byte(`{"agent":{"status":"active"}}`))
}))
defer server.Close()
fixture := prepareDroidFixture(t, droidFixtureOptions{baseURL: server.URL})
installDroidFixture(t, fixture)
hooks := droidCurrentHooks(t, fixture.configPath)
guard := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)
audit := droidManagedHandler(t, hooks["PostToolUse"], fixture.hookPath)
writeDroidTestObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "frozen",
"cached_at": time.Now().UnixMilli(),
})
prePayload := map[string]any{
"session_id": "session-1",
"transcript_path": filepath.Join(fixture.homeDir, "transcript.jsonl"),
"cwd": fixture.workspaceDir,
"permission_mode": "auto-high",
"hook_event_name": "PreToolUse",
"tool_name": "Execute",
"tool_input": map[string]any{"command": "echo test"},
}
preRaw, _ := json.Marshal(prePayload)
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, string(preRaw))
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, `Agent "droid" is frozen`) {
t.Fatalf("guard result = %#v", guardResult)
}
postPayload := make(map[string]any, len(prePayload)+1)
for key, value := range prePayload {
postPayload[key] = value
}
postPayload["hook_event_name"] = "PostToolUse"
postPayload["tool_response"] = map[string]any{"output": "test", "success": true}
postRaw, _ := json.Marshal(postPayload)
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, string(postRaw))
if auditResult.exitCode != 0 {
t.Fatalf("audit result = %#v", auditResult)
}
requestLock.Lock()
defer requestLock.Unlock()
var operation map[string]any
for _, request := range requests {
if request.method == http.MethodPost {
if err := json.Unmarshal(request.body, &operation); err != nil {
t.Fatalf("decode audit request: %v", err)
}
break
}
}
if operation == nil || !reflect.DeepEqual(operation["payload"], postPayload) {
t.Fatalf("native operation = %#v", operation)
}
if !reflect.DeepEqual(operation["subject"], map[string]any{"session_id": "session-1"}) ||
!reflect.DeepEqual(operation["action"], map[string]any{"tool": "Execute"}) {
t.Fatalf("native operation identity = %#v", operation)
}
}
func TestDroidStatusRequiresExactHooksAndRuntimeSources(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture) 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)
} }
+182 -76
View File
@@ -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,33 +13,45 @@ 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 {
plugin *DroidPlugin plugin *DroidPlugin
config InstallConfig config InstallConfig
homeDir string homeDir string
workspaceDir string workspaceDir string
factoryDir string factoryDir string
configPath string configPath string
legacyPath string legacyPath string
settingsPath string settingsPath string
agentDir string localSettingsPath string
guardPath string projectSettingsPath string
hookPath string projectLocalSettingsPath string
runtimeConfig string systemSettingsPath string
privateKey string agentDir string
guardPath string
hookPath string
runtimeConfig string
privateKey string
} }
type droidCommandResult struct { type droidCommandResult struct {
exitCode int exitCode int
stdout string
stderr string stderr string
} }
@@ -57,51 +70,78 @@ 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)
}
} }
if !options.skipGuard { writeOptionalDroidFile(t, configPath, options.root)
guard := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n" writeOptionalDroidFile(t, legacyPath, options.legacy)
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,
workspaceDir: workspaceDir, workspaceDir: workspaceDir,
factoryDir: factoryDir, factoryDir: factoryDir,
configPath: configPath, configPath: configPath,
legacyPath: legacyPath, legacyPath: legacyPath,
settingsPath: settingsPath, settingsPath: settingsPath,
agentDir: agentDir, localSettingsPath: localSettingsPath,
guardPath: guardPath, projectSettingsPath: projectSettingsPath,
hookPath: hookPath, projectLocalSettingsPath: projectLocalSettingsPath,
runtimeConfig: filepath.Join(agentDir, "config.json"), systemSettingsPath: systemSettingsPath,
privateKey: filepath.Join(agentDir, "private.key"), agentDir: agentDir,
guardPath: guardPath,
hookPath: hookPath,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
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)
}